Activating NIP-95 support

This commit is contained in:
Vitor Pamplona 2023-04-26 18:04:38 -04:00
parent e370e75ba4
commit ab747f5e93
10 changed files with 136 additions and 30 deletions

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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 }

View File

@ -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
}

View File

@ -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<ZoomableContent?>(null) }
LaunchedEffect(key1 = eventHeader.id) {
LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
withContext(Dispatchers.IO) {
val bytes = eventBytes?.decode()
val blurHash = eventHeader.blurhash()