From ab747f5e933f8b4583c4055efd4aa55782b94e3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 18:04:38 -0400 Subject: [PATCH] Activating NIP-95 support --- .../vitorpamplona/amethyst/model/Account.kt | 28 ++++++++ .../amethyst/model/LocalCache.kt | 30 +++++++++ .../amethyst/service/FileHeader.kt | 13 ++-- .../amethyst/service/NostrDataSource.kt | 2 + .../amethyst/service/model/Event.kt | 2 + .../service/model/FileStorageHeaderEvent.kt | 2 + .../amethyst/service/relays/Relay.kt | 12 ++-- .../amethyst/ui/actions/NewPostView.kt | 5 +- .../amethyst/ui/actions/NewPostViewModel.kt | 65 +++++++++++++++---- .../amethyst/ui/note/NoteCompose.kt | 7 +- 10 files changed, 136 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 428656963..de76b406e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -375,6 +375,34 @@ class Account( } } + fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? { + if (!isWriteable()) return null + + val data = FileStorageEvent.create( + mimeType = headerInfo.mimeType ?: "", + data = data, + privateKey = loggedIn.privKey!! + ) + + val signedEvent = FileStorageHeaderEvent.create( + data, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + blurhash = headerInfo.blurHash, + description = headerInfo.description, + privateKey = loggedIn.privKey!! + ) + + Client.send(data) + LocalCache.consume(data) + + Client.send(signedEvent) + LocalCache.consume(signedEvent) + + return LocalCache.notes[signedEvent.id] + } + fun sendHeader(headerInfo: FileHeader): Note? { if (!isWriteable()) return null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1b489bc41..fccf22bea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -666,6 +666,32 @@ object LocalCache { refreshObservers(note) } + fun consume(event: FileStorageHeaderEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume(event: FileStorageEvent) { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun consume(event: LnZapPaymentRequestEvent) { // Does nothing without a response callback. } @@ -723,6 +749,10 @@ object LocalCache { } } + fun pruneFileStorageEvents(account: Account) { + notes.filter { it.value.event is FileStorageEvent } + } + fun pruneOldAndHiddenMessages(account: Account) { channels.forEach { it -> val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index c4e6ff8ee..4c21971e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -19,17 +19,22 @@ class FileHeader( ) { companion object { fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + val imageData = URL(fileUrl).readBytes() + + prepare(imageData, fileUrl, mimeType, description, onReady, onError) + } + + fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { try { - val imageData = URL(fileUrl).readBytes() val sha256 = MessageDigest.getInstance("SHA-256") - val hash = sha256.digest(imageData).toHexKey() - val size = imageData.size + val hash = sha256.digest(data).toHexKey() + val size = data.size val blurHash = if (mimeType?.startsWith("image/") == true) { val opt = BitmapFactory.Options() opt.inPreferredConfig = Bitmap.Config.ARGB_8888 - val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt) + val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) val intArray = IntArray(mBitmap.width * mBitmap.height) mBitmap.getPixels( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index bdabd984f..b5789a178 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -77,6 +77,8 @@ abstract class NostrDataSource(val debugName: String) { is DeletionEvent -> LocalCache.consume(event) is FileHeaderEvent -> LocalCache.consume(event) + is FileStorageEvent -> LocalCache.consume(event) + is FileStorageHeaderEvent -> LocalCache.consume(event) is LnZapEvent -> { event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 9b63fa799..151f17621 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -223,6 +223,8 @@ open class Event( DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt index f7d7f88a8..ae2fd5cdf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt @@ -14,6 +14,8 @@ class FileStorageHeaderEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 653a1fdf6..274fd675c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -96,8 +96,6 @@ class Relay( val type = msg[0].asString val channel = msg[1].asString - // Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") - when (type) { "EVENT" -> { val event = Event.fromJson(msg[2], Client.lenient) @@ -116,15 +114,15 @@ class Relay( it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - // Log.w("Relay", "Relay onNotice $url, $channel") + Log.w("Relay", "Relay onNotice $url, $channel") it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - // Log.w("Relay", "AUTHSENT Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}") + Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } "AUTH" -> listeners.forEach { - // Log.w("Relay", "Relay AUTHSENT $url, ${msg[1].asString}") + // Log.w("Relay", "Relay$url, ${msg[1].asString}") it.onAuth(this@Relay, msg[1].asString) } else -> listeners.forEach { @@ -207,8 +205,8 @@ class Relay( val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() } if (filters.isNotEmpty()) { val request = - """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]""" - // println("FILTERSSENT $url $request") + """["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]""" + //println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 7180b3b17..2350c0907 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -489,7 +489,7 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } enum class ServersAvailable { - IMGUR, NOSTR_BUILD, NOSTR_IMG + IMGUR, NOSTR_BUILD, NOSTR_IMG, NIP95 } @Composable @@ -508,7 +508,8 @@ fun ImageVideoDescription( val fileServers = listOf( Pair(ServersAvailable.IMGUR, "imgur.com"), - Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com") + Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com"), + Pair(ServersAvailable.NIP95, "your relays (NIP-95)") ) val fileServerOptions = fileServers.map { it.second } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 77ff7a0ac..8494fe4d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -114,20 +114,29 @@ open class NewPostViewModel : ViewModel() { isUploadingImage = true contentToAddUrl = null - ImageUploader.uploadImage( - uri = it, - server = server, - contentResolver = context.contentResolver, - onSuccess = { imageUrl, mimeType -> - createNIP97Record(imageUrl, mimeType, description) - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } + val contentResolver = context.contentResolver + + if (server == ServersAvailable.NIP95) { + val contentType = contentResolver.getType(it) + contentResolver.openInputStream(it)?.use { + createNIP95Record(it.readBytes(), contentType, description) } - ) + } else { + ImageUploader.uploadImage( + uri = it, + server = server, + contentResolver = contentResolver, + onSuccess = { imageUrl, mimeType -> + createNIP94Record(imageUrl, mimeType, description) + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } } open fun cancel() { @@ -212,7 +221,7 @@ open class NewPostViewModel : ViewModel() { } } - fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) { + fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) { viewModelScope.launch(Dispatchers.IO) { // Images don't seem to be ready immediately after upload @@ -249,6 +258,34 @@ open class NewPostViewModel : ViewModel() { } } + fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) { + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + "", + mimeType, + description, + onReady = { + val note = account?.sendNip95(bytes, headerInfo = it) + + isUploadingImage = false + + note?.let { + message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + fun selectImage(uri: Uri) { contentToAddUrl = uri } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 7f463a827..bdc8aba6f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -810,17 +810,18 @@ fun FileHeaderDisplay(note: Note) { @Composable fun FileStorageHeaderDisplay(baseNote: Note) { - val fileNote = baseNote.replyTo?.firstOrNull() ?: return + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + + val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return val noteState by fileNote.live().metadata.observeAsState() val note = noteState?.note val eventBytes = (note?.event as? FileStorageEvent) - val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return var content by remember { mutableStateOf(null) } - LaunchedEffect(key1 = eventHeader.id) { + LaunchedEffect(key1 = eventHeader.id, key2 = noteState) { withContext(Dispatchers.IO) { val bytes = eventBytes?.decode() val blurHash = eventHeader.blurhash()