From 3175d4048274edb8db473c5f464206b1f367a59a Mon Sep 17 00:00:00 2001 From: Zoltan <34719275+ZsZolee@users.noreply.github.com> Date: Wed, 26 Apr 2023 11:08:13 +0200 Subject: [PATCH 01/35] Zap Type Hungarian translation --- app/src/main/res/values-hu/strings.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 8dbb1694e..ab2e4a80a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -301,4 +301,18 @@ A tartalom leírása Naplementekor egy kék csónak egy fehér homokos tengerparton + Zap Típusa + Zap típus minden opcióhoz + + Publikus + Mindenki láthatja a tranzakciót és az üzenetet + + Privát + A küldő és a fogadó láthatja egymást és az üzenetet is elolvashatja + + Névtelen + A címzett és a nyilvánosság nem tudja ki küldte a fizetést + + Nem Zap + Nostr-ban nyoma sincs, csak a Lightning-ben From c6a0b0950ac361a4c9a417d7c7435eba8fafa1dd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 13:51:22 -0400 Subject: [PATCH 02/35] Fixes AUTH NIP42 bug. Amethyst was sending the event as a regular EVENT and not an AUTH command --- .../amethyst/service/relays/Relay.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 68137cd57..653a1fdf6 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 @@ -5,6 +5,7 @@ import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface +import com.vitorpamplona.amethyst.service.model.RelayAuthEvent import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -119,11 +120,11 @@ class Relay( it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - // Log.w("Relay", "Relay on OK $url, $channel") + // Log.w("Relay", "AUTHSENT 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 AUTH $url, $channel") + // Log.w("Relay", "Relay AUTHSENT $url, ${msg[1].asString}") it.onAuth(this@Relay, msg[1].asString) } else -> listeners.forEach { @@ -234,11 +235,19 @@ class Relay( } fun send(signedEvent: EventInterface) { - if (write) { - val event = """["EVENT",${signedEvent.toJson()}]""" + if (signedEvent is RelayAuthEvent) { + val event = """["AUTH",${signedEvent.toJson()}]""" socket?.send(event) eventUploadCounterInBytes += event.bytesUsedInMemory() } + + if (write) { + if (signedEvent !is RelayAuthEvent) { + val event = """["EVENT",${signedEvent.toJson()}]""" + socket?.send(event) + eventUploadCounterInBytes += event.bytesUsedInMemory() + } + } } fun close(subscriptionId: String) { From 86fe9b4a65ae468a4adaa98f9b36a1d9e8821738 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 14:22:49 -0400 Subject: [PATCH 03/35] Adds support for NIP-95 Images --- .../amethyst/service/model/FileHeaderEvent.kt | 2 +- .../service/model/FileStorageEvent.kt | 58 +++++ .../service/model/FileStorageHeaderEvent.kt | 67 ++++++ .../amethyst/ui/actions/SaveToGallery.kt | 60 +++++ .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/ui/components/VideoView.kt | 4 + .../ui/components/ZoomableContentView.kt | 207 +++++++++++++++--- .../amethyst/ui/note/NoteCompose.kt | 42 +++- 8 files changed, 411 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt index 53988d943..5c91fcc80 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -48,7 +48,7 @@ class FileHeaderEvent( privateKey: ByteArray, createdAt: Long = Date().time / 1000 ): FileHeaderEvent { - var tags = listOfNotNull( + val tags = listOfNotNull( listOf(URL, url), mimeType?.let { listOf(MIME_TYPE, mimeType) }, hash?.let { listOf(HASH, it) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt new file mode 100644 index 000000000..af25b4152 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt @@ -0,0 +1,58 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Base64 +import java.util.Date + +class FileStorageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } + + fun decode(): ByteArray? { + return try { + Base64.getDecoder().decode(content) + } catch (e: Exception) { + Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") + null + } + } + + companion object { + const val kind = 1064 + + private const val TYPE = "type" + private const val DECRYPT = "decrypt" + + fun encode(bytes: ByteArray): String { + return Base64.getEncoder().encodeToString(bytes) + } + + fun create( + mimeType: String, + data: ByteArray, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileStorageEvent { + val tags = listOfNotNull( + listOf(TYPE, mimeType) + ) + + val content = encode(data) + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} 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 new file mode 100644 index 000000000..f7d7f88a8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt @@ -0,0 +1,67 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class FileStorageHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + 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) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + companion object { + const val kind = 1065 + + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + + fun create( + storageEvent: FileStorageEvent, + mimeType: String? = null, + description: String? = null, + hash: String? = null, + size: String? = null, + blurhash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileStorageHeaderEvent { + val tags = listOfNotNull( + listOf("e", storageEvent.id), + mimeType?.let { listOf(MIME_TYPE, mimeType) }, + hash?.let { listOf(HASH, it) }, + size?.let { listOf(FILE_SIZE, it) }, + blurhash?.let { listOf(BLUR_HASH, it) }, + magnetURI?.let { listOf(MAGNET_URI, it) }, + torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) } + ) + + val content = description ?: "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt index e8dbca2cf..1da278cbc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt @@ -83,3 +83,63 @@ fun SaveToGallery(url: String) { Text(text = stringResource(id = R.string.save), color = Color.White) } } + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun SaveToGallery(byteArray: ByteArray, mimeType: String?) { + val localContext = LocalContext.current + val scope = rememberCoroutineScope() + + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + byteArray = byteArray, + mimeType = mimeType, + onSuccess = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.image_saved_to_the_gallery), + Toast.LENGTH_SHORT + ) + .show() + } + }, + onError = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.failed_to_save_the_image), + Toast.LENGTH_SHORT + ) + .show() + } + } + ) + } + + val writeStoragePermissionState = rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) { isGranted -> + if (isGranted) { + saveImage() + } + } + + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = Color.Gray + ) + ) { + Text(text = stringResource(id = R.string.save), color = Color.White) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index c2ed2dfb0..ee78c5bee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -142,9 +142,9 @@ fun RichTextViewer( val imagesForPager = urlSet.mapNotNull { fullUrl -> val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableVideo(fullUrl) + ZoomableUrlVideo(fullUrl) } else { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 21b0efb53..59c7ec844 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -95,3 +95,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } } } + +@Composable +fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index cc840bf07..55292064d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -6,6 +6,7 @@ import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -73,23 +74,47 @@ import net.engawapg.lib.zoomable.zoomable import java.security.MessageDigest abstract class ZoomableContent( - val url: String, - val description: String? = null, - val hash: String? = null + val description: String? = null ) -class ZoomableImage( +abstract class ZoomableUrlContent( + val url: String, + description: String? = null, + val hash: String? = null +) : ZoomableContent(description) + +class ZoomableUrlImage( url: String, description: String? = null, hash: String? = null, val bluehash: String? = null -) : ZoomableContent(url, description, hash) +) : ZoomableUrlContent(url, description, hash) -class ZoomableVideo( +class ZoomableUrlVideo( url: String, description: String? = null, hash: String? = null -) : ZoomableContent(url, description, hash) +) : ZoomableUrlContent(url, description, hash) + +abstract class ZoomablePreloadedContent( + description: String? = null, + val isVerified: Boolean? = null +) : ZoomableContent(description) + +class ZoomableBitmapImage( + val byteArray: ByteArray?, + val mimeType: String? = null, + description: String? = null, + val bluehash: String? = null, + isVerified: Boolean? = null +) : ZoomablePreloadedContent(description, isVerified) + +class ZoomableBytesVideo( + val byteArray: ByteArray, + val mimeType: String? = null, + description: String? = null, + isVerified: Boolean? = null +) : ZoomablePreloadedContent(description, isVerified) fun figureOutMimeType(fullUrl: String): ZoomableContent { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() @@ -97,11 +122,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent { val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } return if (isImage) { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } else if (isVideo) { - ZoomableVideo(fullUrl) + ZoomableUrlVideo(fullUrl) } else { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } } @@ -126,15 +151,33 @@ fun ZoomableContentView(content: ZoomableContent, images: List mutableStateOf(null) } - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) + if (content is ZoomableUrlContent) { + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + } else if (content is ZoomableBitmapImage) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } + } + } + } else if (content is ZoomableBytesVideo) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } } } } - val mainImageModifier = Modifier + var mainImageModifier = Modifier .fillMaxWidth() .clip(shape = RoundedCornerShape(15.dp)) .border( @@ -142,12 +185,19 @@ fun ZoomableContentView(content: ZoomableContent, images: List MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp) ) - .combinedClickable( + + if (content is ZoomableUrlContent) { + mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } ) + } else { + mainImageModifier = mainImageModifier.clickable { + dialogOpen = true + } + } - if (content is ZoomableImage) { + if (content is ZoomableUrlImage) { Box() { AsyncImage( model = content.url, @@ -174,8 +224,36 @@ fun ZoomableContentView(content: ZoomableContent, images: List DisplayUrlWithLoadingSymbol(content) } } - } else { + } else if (content is ZoomableUrlVideo) { VideoView(content.url, content.description) { dialogOpen = true } + } else if (content is ZoomableBitmapImage) { + Box() { + AsyncImage( + model = content.byteArray, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState is AsyncImagePainter.State.Success) { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } + } else if (content is ZoomableBytesVideo) { } if (dialogOpen) { @@ -185,7 +263,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { - ClickableUrl(urlText = "${content.url} ", url = content.url) + if (content is ZoomableUrlContent) { + ClickableUrl(urlText = "${content.url} ", url = content.url) + } else { + Text("Loading content... ") + } val myId = "inlineContent" val emptytext = buildAnnotatedString { @@ -220,7 +302,26 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { @Composable private fun DisplayBlueHash( - content: ZoomableImage, + content: ZoomableUrlImage, + modifier: Modifier +) { + if (content.bluehash == null) return + + val context = LocalContext.current + AsyncImage( + model = BlurHashRequester.imageRequest( + context, + content.bluehash + ), + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = modifier + ) +} + +@Composable +private fun DisplayBlueHash( + content: ZoomableBitmapImage, modifier: Modifier ) { if (content.bluehash == null) return @@ -264,7 +365,12 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List 1) { @@ -297,15 +403,33 @@ private fun RenderImageOrVideo(content: ZoomableContent) { mutableStateOf(null) } - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) + if (content is ZoomableUrlContent) { + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + } else if (content is ZoomableBitmapImage) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } + } + } + } else if (content is ZoomableBytesVideo) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } } } } - if (content is ZoomableImage) { + if (content is ZoomableUrlImage) { Box() { AsyncImage( model = content.url, @@ -327,15 +451,42 @@ private fun RenderImageOrVideo(content: ZoomableContent) { HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } } - } else { + } else if (content is ZoomableUrlVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { VideoView(content.url, content.description) } + } else if (content is ZoomableBitmapImage) { + Box() { + AsyncImage( + model = content.byteArray, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()), + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState !is AsyncImagePainter.State.Success) { + DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else if (content is ZoomableBytesVideo) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoView(content.byteArray, content.description) + } } } @OptIn(ExperimentalCoilApi::class) -private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? { +private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? { if (content.hash == null) return null context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> 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 cb53c7175..7f463a827 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 @@ -169,6 +169,8 @@ fun NoteComposeInner( BadgeDisplay(baseNote = note) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note) } else { var isNew by remember { mutableStateOf(false) } @@ -794,9 +796,9 @@ fun FileHeaderDisplay(note: Note) { val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } content = if (isImage) { - ZoomableImage(fullUrl, description, hash, blurHash) + ZoomableUrlImage(fullUrl, description, hash, blurHash) } else { - ZoomableVideo(fullUrl, description, hash) + ZoomableUrlVideo(fullUrl, description, hash) } } } @@ -806,6 +808,42 @@ fun FileHeaderDisplay(note: Note) { } ?: UrlPreview(fullUrl, "$fullUrl ") } +@Composable +fun FileStorageHeaderDisplay(baseNote: Note) { + val fileNote = baseNote.replyTo?.firstOrNull() ?: 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) { + withContext(Dispatchers.IO) { + val bytes = eventBytes?.decode() + val blurHash = eventHeader.blurhash() + val description = eventHeader.content + val mimeType = eventHeader.mimeType() + + content = if (mimeType?.startsWith("image") == true) { + ZoomableBitmapImage(bytes, mimeType, description, blurHash, true) + } else { + if (bytes != null) { + ZoomableBytesVideo(bytes, mimeType, description, true) + } else { + null + } + } + } + } + + content?.let { + ZoomableContentView(content = it, listOf(it)) + } ?: BlankNote() +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { Row( From a669e3777427c84730c02e156ed4ac4f134fd159 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 14:23:06 -0400 Subject: [PATCH 04/35] Adds server selection support --- .../amethyst/LocalPreferences.kt | 9 +++ .../vitorpamplona/amethyst/model/Account.kt | 8 ++ .../amethyst/ui/actions/ImageSaver.kt | 36 +++++++++ .../amethyst/ui/actions/ImageUploader.kt | 1 + .../amethyst/ui/actions/NewPostView.kt | 76 ++++++++++++++----- .../amethyst/ui/actions/NewPostViewModel.kt | 3 +- .../ui/actions/NewUserMetadataViewModel.kt | 1 + .../ui/screen/loggedIn/ChannelScreen.kt | 2 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 2 +- app/src/main/res/values/strings.xml | 6 +- 10 files changed, 120 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index f71ef1439..09e7bf954 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.ui.actions.ServersAvailable import com.vitorpamplona.amethyst.ui.note.Nip47URI import fr.acinq.secp256k1.Hex import nostr.postr.Persona @@ -44,6 +45,7 @@ private object PrefKeys { const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" const val DEFAULT_ZAPTYPE = "defaultZapType" + const val DEFAULT_FILE_SERVER = "defaultFileServer" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" @@ -194,6 +196,7 @@ object LocalPreferences { putString(PrefKeys.TRANSLATE_TO, account.translateTo) putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) + putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) @@ -225,6 +228,11 @@ object LocalPreferences { object : TypeToken() {}.type ) ?: LnZapEvent.ZapType.PUBLIC + val defaultFileServer = gson.fromJson( + getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"), + object : TypeToken() {}.type + ) ?: ServersAvailable.IMGUR + val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { gson.fromJson(it, Nip47URI::class.java) @@ -269,6 +277,7 @@ object LocalPreferences { translateTo, zapAmountChoices, defaultZapType, + defaultFileServer, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, 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 e09112dae..428656963 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.RelayPool +import com.vitorpamplona.amethyst.ui.actions.ServersAvailable import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.note.Nip47URI import kotlinx.coroutines.DelicateCoroutinesApi @@ -45,6 +46,7 @@ class Account( var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, + var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -652,6 +654,12 @@ class Account( saveable.invalidateData() } + fun changeDefaultFileServer(server: ServersAvailable) { + defaultFileServer = server + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index 485f21062..ba62883c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -7,13 +7,17 @@ import android.media.MediaScannerConnection import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.vitorpamplona.amethyst.BuildConfig import okhttp3.* import okio.BufferedSource import okio.IOException +import okio.buffer import okio.sink +import okio.source import java.io.File +import java.util.UUID object ImageSaver { /** @@ -74,6 +78,38 @@ object ImageSaver { }) } + fun saveImage( + byteArray: ByteArray, + mimeType: String?, + context: Context, + onSuccess: () -> Any?, + onError: (Throwable) -> Any? + ) { + try { + val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val buffer = byteArray.inputStream().source().buffer() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveContentQ( + displayName = UUID.randomUUID().toString(), + contentType = mimeType ?: "", + contentSource = buffer, + contentResolver = context.contentResolver + ) + } else { + saveContentDefault( + fileName = UUID.randomUUID().toString() + ".$extension", + contentSource = buffer, + context = context + ) + } + onSuccess() + } catch (e: Exception) { + e.printStackTrace() + onError(e) + } + } + @RequiresApi(Build.VERSION_CODES.Q) private fun saveContentQ( displayName: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index cf15b8c3a..ffc78709c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -14,6 +14,7 @@ import java.util.* object ImageUploader { fun uploadImage( uri: Uri, + server: ServersAvailable, contentResolver: ContentResolver, onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit 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 37904090f..e99552cd1 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 @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers @@ -206,8 +207,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n if (url != null) { ImageVideoDescription( url, - onAdd = { description -> - postViewModel.upload(url, description, context) + account.defaultFileServer, + onAdd = { description, server -> + postViewModel.upload(url, description, server, context) + account.changeDefaultFileServer(server) }, onCancel = { postViewModel.contentToAddUrl = null @@ -485,10 +488,15 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } } +enum class ServersAvailable() { + IMGUR +} + @Composable fun ImageVideoDescription( uri: Uri, - onAdd: (String) -> Unit, + defaultServer: ServersAvailable, + onAdd: (String, ServersAvailable) -> Unit, onCancel: () -> Unit ) { val resolver = LocalContext.current.contentResolver @@ -498,6 +506,15 @@ fun ImageVideoDescription( val isImage = mediaType.startsWith("image") val isVideo = mediaType.startsWith("video") + val fileServers = listOf( + Pair(ServersAvailable.IMGUR, "imgur.com") + ) + + val fileServerOptions = fileServers.map { it.second } + + var selectedServer by remember { mutableStateOf(defaultServer) } + var message by remember { mutableStateOf("") } + Column( modifier = Modifier .fillMaxWidth() @@ -595,30 +612,49 @@ fun ImageVideoDescription( } } - var message by remember { mutableStateOf("") } - - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth(), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = fileServers.filter { it.first == defaultServer }.first().second, + options = fileServerOptions, + onSelect = { + selectedServer = fileServers[it].first + }, + modifier = Modifier + .weight(1f) ) - ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + ) + } Button( modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp), onClick = { - onAdd(message) + onAdd(message, selectedServer) }, shape = RoundedCornerShape(15.dp), colors = ButtonDefaults.buttonColors( 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 a07701358..77ff7a0ac 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 @@ -110,12 +110,13 @@ open class NewPostViewModel : ViewModel() { cancel() } - fun upload(it: Uri, description: String, context: Context) { + fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) { isUploadingImage = true contentToAddUrl = null ImageUploader.uploadImage( uri = it, + server = server, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> createNIP97Record(imageUrl, mimeType, description) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 5a252ef02..269f22871 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -170,6 +170,7 @@ class NewUserMetadataViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, + server = account.defaultFileServer, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> onUploading(false) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index fe3602a31..63e0e4d92 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -233,7 +233,7 @@ fun ChannelScreen( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - channelScreenModel.upload(it, "", context) + channelScreenModel.upload(it, "", account.defaultFileServer, context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 5dd3bb2ca..cf75e066a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - chatRoomScreenModel.upload(it, "", context) + chatRoomScreenModel.upload(it, "", account.defaultFileServer, context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbab09bf3..5fb553efd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,7 +303,8 @@ Add Image Add Video Add Document - Create and Add + + Add to Message Description of the contents A blue boat in a white sandy beach at sunset @@ -322,4 +323,7 @@ Non-Zap No trace in Nostr, only in Lightning + + File Server + From e370e75ba42f23f42f34712e2d93408b98e3b8c1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 16:36:52 -0400 Subject: [PATCH 05/35] Offers NostrImg as a choice of image server --- .../amethyst/ImageUploadTesting.kt | 91 +++++++++++++++ .../service/NostrSingleEventDataSource.kt | 3 +- .../amethyst/ui/actions/ImageUploader.kt | 107 +++++++++++++++--- .../amethyst/ui/actions/NewPostView.kt | 9 +- 4 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt new file mode 100644 index 000000000..c8803f82d --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -0,0 +1,91 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.ImageUploader +import com.vitorpamplona.amethyst.ui.actions.ImgurServer +import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer +import com.vitorpamplona.amethyst.ui.actions.NostrImgServer +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.fail +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class ImageUploadTesting { + + val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==" + + @Test() + fun testImgurUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + ImgurServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } + + @Test() + @Ignore + fun testNostrBuildUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + NostrBuildServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } + + @Test() + fun testNostrImgUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + NostrImgServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 61c45d61a..85746e500 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -116,7 +116,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { LnZapEvent.kind, LnZapRequestEvent.kind, ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, - PrivateDmEvent.kind, FileHeaderEvent.kind + PrivateDmEvent.kind, + FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind ), ids = interestedEvents.toList() ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index ffc78709c..6238ddf9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.ContentResolver import android.net.Uri +import android.webkit.MimeTypeMap import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import okhttp3.* @@ -9,9 +10,15 @@ import okhttp3.MediaType.Companion.toMediaType import okio.BufferedSink import okio.source import java.io.IOException +import java.io.InputStream import java.util.* +val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + +fun randomChars() = List(16) { charPool.random() }.joinToString("") + object ImageUploader { + fun uploadImage( uri: Uri, server: ServersAvailable, @@ -20,9 +27,33 @@ object ImageUploader { onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(uri) - val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + val imageInputStream = contentResolver.openInputStream(uri) - val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload" + checkNotNull(imageInputStream) { + "Can't open the image input stream" + } + + val myServer = if (server == ServersAvailable.IMGUR) { + ImgurServer() + } else if (server == ServersAvailable.NOSTR_IMG) { + NostrImgServer() + } else { + ImgurServer() + } + + uploadImage(imageInputStream, contentType, myServer, onSuccess, onError) + } + + fun uploadImage( + inputStream: InputStream, + contentType: String?, + server: FileServer, + onSuccess: (String, String?) -> Unit, + onError: (Throwable) -> Unit + ) { + val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + val fileName = randomChars() + val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = OkHttpClient.Builder().build() @@ -30,37 +61,37 @@ object ImageUploader { .setType(MultipartBody.FORM) .addFormDataPart( category, - "${UUID.randomUUID()}", + "$fileName.$extension", object : RequestBody() { override fun contentType(): MediaType? = contentType?.toMediaType() override fun writeTo(sink: BufferedSink) { - val imageInputStream = contentResolver.openInputStream(uri) - checkNotNull(imageInputStream) { - "Can't open the image input stream" - } - - imageInputStream.source().use(sink::writeAll) + inputStream.source().use(sink::writeAll) } } ) .build() - val request: Request = Request.Builder() - .header("Authorization", "Client-ID e6aea87296f3f96") + val requestBuilder = Request.Builder() + + server.clientID()?.let { + requestBuilder.header("Authorization", it) + } + + requestBuilder .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) + .url(server.postUrl(contentType)) .post(requestBody) - .build() + + val request = requestBuilder.build() client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { try { check(response.isSuccessful) response.body.use { body -> - val tree = jacksonObjectMapper().readTree(body.string()) - val url = tree?.get("data")?.get("link")?.asText() + val url = server.parseUrlFromSucess(body.string()) checkNotNull(url) { "There must be an uploaded image URL in the response" } @@ -80,3 +111,49 @@ object ImageUploader { }) } } + +abstract class FileServer { + abstract fun postUrl(contentType: String?): String + abstract fun parseUrlFromSucess(body: String): String? + + open fun clientID(): String? = null +} + +class NostrImgServer : FileServer() { + override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload" + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = null +} + +class ImgurServer : FileServer() { + override fun postUrl(contentType: String?): String { + val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload" + } + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = "Client-ID e6aea87296f3f96" +} + +class NostrBuildServer : FileServer() { + override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php" + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = null +} 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 e99552cd1..7180b3b17 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 @@ -488,8 +488,8 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } } -enum class ServersAvailable() { - IMGUR +enum class ServersAvailable { + IMGUR, NOSTR_BUILD, NOSTR_IMG } @Composable @@ -507,7 +507,8 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Pair(ServersAvailable.IMGUR, "imgur.com") + Pair(ServersAvailable.IMGUR, "imgur.com"), + Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com") ) val fileServerOptions = fileServers.map { it.second } @@ -618,7 +619,7 @@ fun ImageVideoDescription( ) { TextSpinner( label = stringResource(id = R.string.file_server), - placeholder = fileServers.filter { it.first == defaultServer }.first().second, + placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second, options = fileServerOptions, onSelect = { selectedServer = fileServers[it].first From ab747f5e933f8b4583c4055efd4aa55782b94e3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 18:04:38 -0400 Subject: [PATCH 06/35] 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() From 04e4c4692f772bd2acb750c53b036d61f4081166 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 18:05:02 -0400 Subject: [PATCH 07/35] disabling logs --- .../java/com/vitorpamplona/amethyst/service/relays/Relay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 274fd675c..60b7cb31b 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 @@ -206,7 +206,7 @@ class Relay( if (filters.isNotEmpty()) { val request = """["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]""" - //println("FILTERSSENT $url $request") + // println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false From c60ad472e83d0de09d7758f85a9b45d8394fa392 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 18:05:19 -0400 Subject: [PATCH 08/35] v0.37.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 680e8c2af..27f9c25bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 126 - versionName "0.36.0" + versionCode 127 + versionName "0.37.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From d4a3ee26518decb2911cf9400bd842fbde8373eb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Apr 2023 19:04:01 -0400 Subject: [PATCH 09/35] Adding nip95 to readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c6d9da94b..3192471aa 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Badges (NIP-58) - [x] Hashtag Following and Custom Hashtags - [x] Polls (NIP-69) -- [x] Verifiable uploads (NIP-94) +- [x] Verifiable static content in URLs (NIP-94) - [x] Login with QR - [x] Wallet Connect API (NIP-47) - [x] Accessible uploads @@ -53,6 +53,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Multiple Accounts - [x] Markdown Support - [x] Relay Authentication (NIP-42) +- [x] Content stored in relays themselves (NIP-95) - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) From 1596a7b5597fff66ce4f2a2cad7f7b58db1cfe4b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 09:26:26 -0400 Subject: [PATCH 10/35] Pruning file storage events when pausing the app. --- .../main/java/com/vitorpamplona/amethyst/ServiceManager.kt | 1 + .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index fbff99b63..3810ddd2c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -72,6 +72,7 @@ object ServiceManager { LocalCache.pruneOldAndHiddenMessages(it) LocalCache.pruneHiddenMessages(it) LocalCache.pruneContactLists(it) + LocalCache.pruneFileStorageEvents(it) // LocalCache.pruneNonFollows(it) } } 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 fccf22bea..d2e76ffab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -750,7 +750,11 @@ object LocalCache { } fun pruneFileStorageEvents(account: Account) { - notes.filter { it.value.event is FileStorageEvent } + val toBeRemoved = notes.filter { it.value.event is FileStorageEvent } + + toBeRemoved.forEach { + notes.remove(it.key) + } } fun pruneOldAndHiddenMessages(account: Account) { From 0a11de18927facfdfa4e0374465f3b28e9543a7e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 09:27:29 -0400 Subject: [PATCH 11/35] Adds Zap Address preference to the zap tag https://github.com/nostr-protocol/nips/pull/402/files --- .../main/java/com/vitorpamplona/amethyst/service/model/Event.kt | 2 ++ .../com/vitorpamplona/amethyst/service/model/EventInterface.kt | 2 ++ .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) 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 151f17621..33c09bbe5 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 @@ -41,6 +41,8 @@ open class Event( fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1) + fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { val aTagValue = it[1] val relay = it.getOrNull(2) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt index 709a7dacd..5a4e1db57 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -33,4 +33,6 @@ interface EventInterface { fun getReward(): BigDecimal? fun getPoWRank(): Int + + fun zapAddress(): String? } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8cce0da97..cad01018d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -60,7 +60,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { } fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { - val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() + val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() if (lud16.isNullOrBlank()) { onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)) From f10518873841d0e96172feafbb0ee8445fdbb934 Mon Sep 17 00:00:00 2001 From: vivganes Date: Thu, 27 Apr 2023 21:13:25 +0530 Subject: [PATCH 12/35] Add support for non-english hashtags too --- .../com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index ee78c5bee..25e05d8eb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -59,7 +59,7 @@ val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\ val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)") -val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE) +val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) val urlPattern: Pattern = Patterns.WEB_URL fun isValidURL(url: String?): Boolean { From 2ca877b3c3e0b855f4a9bd03c26783ceefe1c46d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 14:43:28 -0400 Subject: [PATCH 13/35] Zap Forwarding --- .../vitorpamplona/amethyst/model/Account.kt | 15 ++- .../service/model/ChannelMessageEvent.kt | 5 +- .../amethyst/service/model/PollNoteEvent.kt | 8 +- .../amethyst/service/model/PrivateDmEvent.kt | 4 + .../amethyst/service/model/TextNoteEvent.kt | 5 + .../amethyst/ui/actions/NewPostView.kt | 113 +++++++++++++++--- .../amethyst/ui/actions/NewPostViewModel.kt | 85 ++++++++++--- app/src/main/res/values/strings.xml | 2 +- 8 files changed, 196 insertions(+), 41 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 de76b406e..2a1cfc26f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -422,7 +422,7 @@ class Account( return LocalCache.notes[signedEvent.id] } - fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null, zapReceiver: String? = null) { if (!isWriteable()) return val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } @@ -435,6 +435,7 @@ class Account( mentions = mentionsHex, addresses = addresses, extraTags = tags, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!! ) @@ -450,7 +451,8 @@ class Account( valueMaximum: Int?, valueMinimum: Int?, consensusThreshold: Int?, - closedAt: Int? + closedAt: Int?, + zapReceiver: String? = null ) { if (!isWriteable()) return @@ -468,14 +470,15 @@ class Account( valueMaximum = valueMaximum, valueMinimum = valueMinimum, consensusThreshold = consensusThreshold, - closedAt = closedAt + closedAt = closedAt, + zapReceiver = zapReceiver ) // println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) Client.send(signedEvent) LocalCache.consume(signedEvent) } - fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?) { + fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: String? = null) { if (!isWriteable()) return // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } @@ -487,13 +490,14 @@ class Account( channel = toChannel, replyTos = repliesToHex, mentions = mentionsHex, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!! ) Client.send(signedEvent) LocalCache.consume(signedEvent, null) } - fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?) { + fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null) { if (!isWriteable()) return val user = LocalCache.users[toUser] ?: return @@ -506,6 +510,7 @@ class Account( msg = message, replyTos = repliesToHex, mentions = mentionsHex, + zapReceiver = zapReceiver, privateKey = loggedIn.privKey!!, advertiseNip18 = false ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt index e2db58fbd..5fb98d1e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt @@ -20,7 +20,7 @@ class ChannelMessageEvent( companion object { const val kind = 42 - fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { + fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { val content = message val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf( @@ -32,6 +32,9 @@ class ChannelMessageEvent( mentions?.forEach { tags.add(listOf("p", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt index c91cd4c7a..0cb71f805 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt @@ -48,7 +48,8 @@ class PollNoteEvent( valueMaximum: Int?, valueMinimum: Int?, consensusThreshold: Int?, - closedAt: Int? + closedAt: Int?, + zapReceiver: String? ): PollNoteEvent { val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() val tags = mutableListOf>() @@ -68,6 +69,11 @@ class PollNoteEvent( tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString())) tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) tags.add(listOf(CLOSED_AT, closedAt.toString())) + + if (zapReceiver != null) { + tags.add(listOf("zap", zapReceiver)) + } + val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index c820d0ca2..c45c27848 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -58,6 +58,7 @@ class PrivateDmEvent( msg: String, replyTos: List? = null, mentions: List? = null, + zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000, publishedRecipientPubKey: ByteArray? = null, @@ -79,6 +80,9 @@ class PrivateDmEvent( mentions?.forEach { tags.add(listOf("p", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index f64582ba3..b9bc6ab55 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -26,6 +26,7 @@ class TextNoteEvent( mentions: List?, addresses: List?, extraTags: List?, + zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000 ): TextNoteEvent { @@ -46,9 +47,13 @@ class TextNoteEvent( extraTags?.forEach { tags.add(listOf("t", it)) } + zapReceiver?.let { + tags.add(listOf("zap", it)) + } findURLs(msg).forEach { tags.add(listOf("r", it)) } + val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) 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 2350c0907..17d67d7ec 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 @@ -17,8 +17,12 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CurrencyBitcoin +import androidx.compose.material.icons.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -205,17 +209,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val url = postViewModel.contentToAddUrl if (url != null) { - ImageVideoDescription( - url, - account.defaultFileServer, - onAdd = { description, server -> - postViewModel.upload(url, description, server, context) - account.changeDefaultFileServer(server) - }, - onCancel = { - postViewModel.contentToAddUrl = null - } - ) + Row(verticalAlignment = Alignment.CenterVertically) { + ImageVideoDescription( + url, + account.defaultFileServer, + onAdd = { description, server -> + postViewModel.upload(url, description, server, context) + account.changeDefaultFileServer(server) + }, + onCancel = { + postViewModel.contentToAddUrl = null + } + ) + } } val user = postViewModel.account?.userProfile() @@ -301,11 +307,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().height(50.dp), verticalAlignment = Alignment.CenterVertically) { UploadFromGallery( isUploading = postViewModel.isUploadingImage, tint = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 10.dp) + modifier = Modifier ) { postViewModel.selectImage(it) } @@ -323,6 +329,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + ForwardZapTo(postViewModel) { + postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo + } } } } @@ -386,6 +396,71 @@ private fun AddLnInvoiceButton( } } +@Composable +private fun ForwardZapTo( + postViewModel: NewPostViewModel, + onClick: () -> Unit +) { + IconButton( + onClick = { + onClick() + } + ) { + Box(Modifier.height(20.dp).width(25.dp)) { + if (!postViewModel.wantsForwardZapTo) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = MaterialTheme.colors.onBackground + ) + Icon( + imageVector = Icons.Default.ArrowForwardIos, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = MaterialTheme.colors.onBackground + ) + } else { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + tint = BitcoinOrange + ) + Icon( + imageVector = Icons.Outlined.ArrowForwardIos, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + tint = BitcoinOrange + ) + } + } + } + + if (postViewModel.wantsForwardZapTo) { + OutlinedTextField( + value = postViewModel.forwardZapToEditting, + onValueChange = { + postViewModel.updateZapForwardTo(it) + }, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).padding(0.dp), + placeholder = { + Text( + text = stringResource(R.string.zap_forward_lnAddress), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + colors = TextFieldDefaults + .outlinedTextFieldColors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } +} + @Composable fun CloseButton(onCancel: () -> Unit) { Button( @@ -555,7 +630,7 @@ fun ImageVideoDescription( fontWeight = FontWeight.W500, modifier = Modifier .padding(start = 10.dp) - .weight(1.0f) + .weight(1.0f).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) IconButton( @@ -579,7 +654,7 @@ fun ImageVideoDescription( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp) + .padding(bottom = 10.dp).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { if (mediaType.startsWith("image")) { AsyncImage( @@ -588,7 +663,7 @@ fun ImageVideoDescription( contentScale = ContentScale.FillWidth, modifier = Modifier .padding(top = 4.dp) - .fillMaxWidth() + .fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { var bitmap by remember { mutableStateOf(null) } @@ -625,18 +700,18 @@ fun ImageVideoDescription( onSelect = { selectedServer = fileServers[it].first }, - modifier = Modifier + modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .weight(1f) ) } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { OutlinedTextField( label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, onValueChange = { message = it }, placeholder = { 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 8494fe4d0..267f8f576 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 @@ -37,6 +37,7 @@ open class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null + var userSuggestionsMainMessage: Boolean? = null // Images and Videos var contentToAddUrl by mutableStateOf(null) @@ -61,6 +62,11 @@ open class NewPostViewModel : ViewModel() { var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf(null) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + open fun load(account: Account, replyingTo: Note?, quote: Note?) { originalNote = replyingTo replyingTo?.let { replyNote -> @@ -90,6 +96,10 @@ open class NewPostViewModel : ViewModel() { canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null contentToAddUrl = null + wantsForwardZapTo = false + forwardZapTo = null + forwardZapToEditting = TextFieldValue("") + this.account = account } @@ -97,14 +107,24 @@ open class NewPostViewModel : ViewModel() { val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text) tagger.run() - if (wantsPoll) { - account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt) - } else if (originalNote?.channel() != null) { - account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions) - } else if (originalNote?.event is PrivateDmEvent) { - account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions) + val zapReceiver = if (wantsForwardZapTo) { + if (forwardZapTo != null) { + forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06 + } else { + forwardZapToEditting.text + } } else { - account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions) + null + } + + if (wantsPoll) { + account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver) + } else if (originalNote?.channel() != null) { + account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver) + } else if (originalNote?.event is PrivateDmEvent) { + account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver) + } else { + account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver) } cancel() @@ -155,6 +175,14 @@ open class NewPostViewModel : ViewModel() { closedAt = null wantsInvoice = false + + wantsForwardZapTo = false + forwardZapTo = null + forwardZapToEditting = TextFieldValue("") + + userSuggestions = emptyList() + userSuggestionAnchor = null + userSuggestionsMainMessage = null } open fun findUrlInMessage(): String? { @@ -176,6 +204,21 @@ open class NewPostViewModel : ViewModel() { if (it.selection.collapsed) { val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") userSuggestionAnchor = it.selection + userSuggestionsMainMessage = true + if (lastWord.startsWith("@") && lastWord.length > 2) { + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + } else { + userSuggestions = emptyList() + } + } + } + + open fun updateZapForwardTo(it: TextFieldValue) { + forwardZapToEditting = it + if (it.selection.collapsed) { + val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = false if (lastWord.startsWith("@") && lastWord.length > 2) { userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) } else { @@ -186,15 +229,29 @@ open class NewPostViewModel : ViewModel() { open fun autocompleteWithUser(item: User) { userSuggestionAnchor?.let { - val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") - val lastWordStart = it.end - lastWord.length - val wordToInsert = "@${item.pubkeyNpub()}" + if (userSuggestionsMainMessage == true) { + val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + message = TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + } else { + val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + forwardZapTo = item + + forwardZapToEditting = TextFieldValue( + forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + } - message = TextFieldValue( - message.text.replaceRange(lastWordStart, it.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) - ) userSuggestionAnchor = null + userSuggestionsMainMessage = null userSuggestions = emptyList() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fb553efd..3db79ad44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,5 +325,5 @@ File Server - + LnAddress or @User From 285a61e38fba83545b2f6742098bed6f1b7e133b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:01:58 -0400 Subject: [PATCH 14/35] Enabling non NIP94 uploads for the PlayStore --- .../amethyst/ui/actions/ImageUploader.kt | 3 +- .../amethyst/ui/actions/NewPostView.kt | 56 ++++++++++++------- .../amethyst/ui/actions/NewPostViewModel.kt | 8 ++- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index 6238ddf9a..7dd5bb23f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -11,7 +11,6 @@ import okio.BufferedSink import okio.source import java.io.IOException import java.io.InputStream -import java.util.* val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') @@ -35,7 +34,7 @@ object ImageUploader { val myServer = if (server == ServersAvailable.IMGUR) { ImgurServer() - } else if (server == ServersAvailable.NOSTR_IMG) { + } else if (server == ServersAvailable.NOSTRIMG) { NostrImgServer() } else { ImgurServer() 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 17d67d7ec..ae9fc3177 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 @@ -564,7 +564,12 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } enum class ServersAvailable { - IMGUR, NOSTR_BUILD, NOSTR_IMG, NIP95 + IMGUR, + NOSTR_BUILD, + NOSTRIMG, + IMGUR_NIP_94, + NOSTRIMG_NIP_94, + NIP95 } @Composable @@ -582,12 +587,15 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Pair(ServersAvailable.IMGUR, "imgur.com"), - Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com"), - Pair(ServersAvailable.NIP95, "your relays (NIP-95)") + Triple(ServersAvailable.IMGUR, "imgur.com", "Uploads to ImgUR. ImgUR can change your image at any time"), + Triple(ServersAvailable.NOSTRIMG, "nostrimg.com", "Regular NostrImg. NostrImg can change your image at any time"), + Triple(ServersAvailable.IMGUR_NIP_94, "Verifiable ImgUr (NIP-94)", "Protects from ImgUr changing your image after you post"), + Triple(ServersAvailable.NOSTRIMG_NIP_94, "Verifiable NostrIMG (NIP-94)", "Protects from NostrIMG changing your image after you post"), + Triple(ServersAvailable.NIP95, "Your relays (NIP-95)", "The image is hosted in the relay itself. Your image will be free from a domain name / third-party control") ) val fileServerOptions = fileServers.map { it.second } + val fileServerExplainers = fileServers.map { it.third } var selectedServer by remember { mutableStateOf(defaultServer) } var message by remember { mutableStateOf("") } @@ -697,6 +705,7 @@ fun ImageVideoDescription( label = stringResource(id = R.string.file_server), placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second, options = fileServerOptions, + explainers = fileServerExplainers, onSelect = { selectedServer = fileServers[it].first }, @@ -705,25 +714,32 @@ fun ImageVideoDescription( ) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || + selectedServer == ServersAvailable.IMGUR_NIP_94 || + selectedServer == ServersAvailable.NIP95 ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences ) - ) + } } Button( 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 267f8f576..22c7994f4 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 @@ -147,7 +147,13 @@ open class NewPostViewModel : ViewModel() { server = server, contentResolver = contentResolver, onSuccess = { imageUrl, mimeType -> - createNIP94Record(imageUrl, mimeType, description) + if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94) { + createNIP94Record(imageUrl, mimeType, description) + } else { + isUploadingImage = false + message = TextFieldValue(message.text + "\n\n" + imageUrl) + urlPreview = findUrlInMessage() + } }, onError = { isUploadingImage = false From 561189144eb818c2f2beef56adeaa44606320629 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:06:04 -0400 Subject: [PATCH 15/35] v0.37.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 27f9c25bb..846c6f530 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 127 - versionName "0.37.0" + versionCode 128 + versionName "0.37.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 362082d6087fdd77bc935d94b5fd3ee964d3df2c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:40:28 -0400 Subject: [PATCH 16/35] Correcly copy pasting NIP94 and NIP95 addresses. --- .../ui/components/ZoomableContentView.kt | 38 +++++++++++++------ .../amethyst/ui/note/NoteCompose.kt | 10 +++-- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 55292064d..bb31eeb9e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -80,25 +80,29 @@ abstract class ZoomableContent( abstract class ZoomableUrlContent( val url: String, description: String? = null, - val hash: String? = null + val hash: String? = null, + val uri: String? = null ) : ZoomableContent(description) class ZoomableUrlImage( url: String, description: String? = null, hash: String? = null, - val bluehash: String? = null -) : ZoomableUrlContent(url, description, hash) + val bluehash: String? = null, + uri: String? = null +) : ZoomableUrlContent(url, description, hash, uri) class ZoomableUrlVideo( url: String, description: String? = null, - hash: String? = null -) : ZoomableUrlContent(url, description, hash) + hash: String? = null, + uri: String? = null +) : ZoomableUrlContent(url, description, hash, uri) abstract class ZoomablePreloadedContent( description: String? = null, - val isVerified: Boolean? = null + val isVerified: Boolean? = null, + val uri: String ) : ZoomableContent(description) class ZoomableBitmapImage( @@ -106,15 +110,17 @@ class ZoomableBitmapImage( val mimeType: String? = null, description: String? = null, val bluehash: String? = null, - isVerified: Boolean? = null -) : ZoomablePreloadedContent(description, isVerified) + isVerified: Boolean? = null, + uri: String +) : ZoomablePreloadedContent(description, isVerified, uri) class ZoomableBytesVideo( val byteArray: ByteArray, val mimeType: String? = null, description: String? = null, - isVerified: Boolean? = null -) : ZoomablePreloadedContent(description, isVerified) + isVerified: Boolean? = null, + uri: String +) : ZoomablePreloadedContent(description, isVerified, uri) fun figureOutMimeType(fullUrl: String): ZoomableContent { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() @@ -189,7 +195,17 @@ fun ZoomableContentView(content: ZoomableContent, images: List if (content is ZoomableUrlContent) { mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } + onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) } + ) + } else if (content is ZoomableBitmapImage) { + mainImageModifier = mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } + ) + } else if (content is ZoomableBytesVideo) { + mainImageModifier = mainImageModifier.combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) } ) } else { mainImageModifier = mainImageModifier.clickable { 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 bdc8aba6f..0f2237fd5 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 @@ -795,10 +795,11 @@ fun FileHeaderDisplay(note: Note) { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + val uri = "nostr:" + note.toNEvent() content = if (isImage) { - ZoomableUrlImage(fullUrl, description, hash, blurHash) + ZoomableUrlImage(fullUrl, description, hash, blurHash, uri) } else { - ZoomableUrlVideo(fullUrl, description, hash) + ZoomableUrlVideo(fullUrl, description, hash, uri) } } } @@ -823,16 +824,17 @@ fun FileStorageHeaderDisplay(baseNote: Note) { LaunchedEffect(key1 = eventHeader.id, key2 = noteState) { withContext(Dispatchers.IO) { + val uri = "nostr:" + baseNote.toNEvent() val bytes = eventBytes?.decode() val blurHash = eventHeader.blurhash() val description = eventHeader.content val mimeType = eventHeader.mimeType() content = if (mimeType?.startsWith("image") == true) { - ZoomableBitmapImage(bytes, mimeType, description, blurHash, true) + ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri) } else { if (bytes != null) { - ZoomableBytesVideo(bytes, mimeType, description, true) + ZoomableBytesVideo(bytes, mimeType, description, true, uri) } else { null } From 2f457d135f90c1a4b4dfecb6f70e9312308cdde9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 15:54:26 -0400 Subject: [PATCH 17/35] Improving translations --- .../amethyst/ui/actions/NewPostView.kt | 62 +++++++++++++------ app/src/main/res/values/strings.xml | 16 +++++ 2 files changed, 60 insertions(+), 18 deletions(-) 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 ae9fc3177..9fea53e30 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 @@ -307,7 +307,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } - Row(modifier = Modifier.fillMaxWidth().height(50.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + verticalAlignment = Alignment.CenterVertically + ) { UploadFromGallery( isUploading = postViewModel.isUploadingImage, tint = MaterialTheme.colors.onBackground, @@ -406,31 +411,43 @@ private fun ForwardZapTo( onClick() } ) { - Box(Modifier.height(20.dp).width(25.dp)) { + Box( + Modifier + .height(20.dp) + .width(25.dp) + ) { if (!postViewModel.wantsForwardZapTo) { Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + modifier = Modifier + .size(20.dp) + .align(Alignment.CenterStart), tint = MaterialTheme.colors.onBackground ) Icon( imageVector = Icons.Default.ArrowForwardIos, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + modifier = Modifier + .size(13.dp) + .align(Alignment.CenterEnd), tint = MaterialTheme.colors.onBackground ) } else { Icon( imageVector = Icons.Outlined.Bolt, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp).align(Alignment.CenterStart), + modifier = Modifier + .size(20.dp) + .align(Alignment.CenterStart), tint = BitcoinOrange ) Icon( imageVector = Icons.Outlined.ArrowForwardIos, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(13.dp).align(Alignment.CenterEnd), + modifier = Modifier + .size(13.dp) + .align(Alignment.CenterEnd), tint = BitcoinOrange ) } @@ -443,7 +460,10 @@ private fun ForwardZapTo( onValueChange = { postViewModel.updateZapForwardTo(it) }, - modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).padding(0.dp), + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .padding(0.dp), placeholder = { Text( text = stringResource(R.string.zap_forward_lnAddress), @@ -587,11 +607,11 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Triple(ServersAvailable.IMGUR, "imgur.com", "Uploads to ImgUR. ImgUR can change your image at any time"), - Triple(ServersAvailable.NOSTRIMG, "nostrimg.com", "Regular NostrImg. NostrImg can change your image at any time"), - Triple(ServersAvailable.IMGUR_NIP_94, "Verifiable ImgUr (NIP-94)", "Protects from ImgUr changing your image after you post"), - Triple(ServersAvailable.NOSTRIMG_NIP_94, "Verifiable NostrIMG (NIP-94)", "Protects from NostrIMG changing your image after you post"), - Triple(ServersAvailable.NIP95, "Your relays (NIP-95)", "The image is hosted in the relay itself. Your image will be free from a domain name / third-party control") + Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), + Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)), + Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), + Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), + Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) ) val fileServerOptions = fileServers.map { it.second } @@ -638,7 +658,8 @@ fun ImageVideoDescription( fontWeight = FontWeight.W500, modifier = Modifier .padding(start = 10.dp) - .weight(1.0f).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .weight(1.0f) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) IconButton( @@ -662,7 +683,8 @@ fun ImageVideoDescription( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp).windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { if (mediaType.startsWith("image")) { AsyncImage( @@ -671,7 +693,8 @@ fun ImageVideoDescription( contentScale = ContentScale.FillWidth, modifier = Modifier .padding(top = 4.dp) - .fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { var bitmap by remember { mutableStateOf(null) } @@ -709,7 +732,8 @@ fun ImageVideoDescription( onSelect = { selectedServer = fileServers[it].first }, - modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + modifier = Modifier + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) .weight(1f) ) } @@ -720,12 +744,14 @@ fun ImageVideoDescription( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) ) { OutlinedTextField( label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), value = message, onValueChange = { message = it }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3db79ad44..921787a85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -326,4 +326,20 @@ File Server LnAddress or @User + + imgur.com - less secure + Uploads to Imgur. Imgur can change your image/video at any time + + nostrimg.com - less secure + Uploads to NostrImg. NostrImg can change your image at any time + + Verifiable Imgur (NIP-94) + Protects from Imgur changing your image/video after you post + + Verifiable NostrImg (NIP-94) + Protects from NostrImg changing your image after you post + + Your relays (NIP-95) + Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list + From 54511dcc6964e18d8ecde59ebc2116fd0272993b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 16:23:30 -0400 Subject: [PATCH 18/35] Updating dependencies --- app/build.gradle | 8 ++++---- build.gradle | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 846c6f530..46d2a3829 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,8 +85,8 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.10.0' - implementation 'androidx.activity:activity-compose:1.7.0-beta01' + implementation 'androidx.core:core-ktx:1.11.0-alpha03' + implementation 'androidx.activity:activity-compose:1.7.0' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" @@ -134,7 +134,7 @@ dependencies { implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1' // Encrypted Key Storage - implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05' + implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06' // view videos implementation 'com.google.android.exoplayer:exoplayer:2.18.6' @@ -177,7 +177,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.13.5' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" diff --git a/build.gradle b/build.gradle index 0c3fda9d7..e93e47bce 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { ext { - fragment_version = "1.5.6" + fragment_version = "1.5.7" lifecycle_version = '2.6.1' compose_ui_version = '1.5.0-alpha03' nav_version = "2.5.3" From f6f82a16f5d6039ab5282048796b8585e75f1f8c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 16:23:38 -0400 Subject: [PATCH 19/35] v0.37.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 46d2a3829..2e268a88d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 128 - versionName "0.37.1" + versionCode 129 + versionName "0.37.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 925289ae64b7921b07dca97e682f65170195c63d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:13:45 -0400 Subject: [PATCH 20/35] Reducing the amount of filters to send to relays to 10 --- .../java/com/vitorpamplona/amethyst/service/relays/Relay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 60b7cb31b..280e7bbe7 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 @@ -205,7 +205,7 @@ class Relay( val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() } if (filters.isNotEmpty()) { val request = - """["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]""" + """["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]""" // println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() From 0fe6bd6c8372f58bebf238ec207479d5bb051e7b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:13:56 -0400 Subject: [PATCH 21/35] Bugfix for clickable url --- .../com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index 1d84fa2e8..d6a082a08 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -11,9 +11,11 @@ import androidx.compose.ui.text.AnnotatedString fun ClickableUrl(urlText: String, url: String) { val uri = LocalUriHandler.current + val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" + ClickableText( text = AnnotatedString(urlText), - onClick = { runCatching { uri.openUri(url) } }, + onClick = { runCatching { uri.openUri(doubleCheckedUrl) } }, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) ) } From ddba4ac5100691244f96aaac7798061580a87f55 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:35:36 -0400 Subject: [PATCH 22/35] Add logs for fail to decrypt zap --- .../main/java/com/vitorpamplona/amethyst/model/Account.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 2a1cfc26f..2a0a11662 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources +import android.util.Log import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.FileHeader @@ -779,7 +780,11 @@ class Account( } if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) + val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) + if (result == null) { + Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}") + } + result } else { null } From 1ae785061e81ec9c84c754e6d3af40a26c518fde Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:35:53 -0400 Subject: [PATCH 23/35] Reverting compose to 1.4.2 --- app/build.gradle | 2 +- .../vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt | 2 +- build.gradle | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2e268a88d..ebc2cd388 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,7 +85,7 @@ android { } dependencies { - implementation 'androidx.core:core-ktx:1.11.0-alpha03' + implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.activity:activity-compose:1.7.0' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 5b6049107..26a563099 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -496,7 +496,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode userBadge.acceptedBadges?.let { note -> (note.event as? BadgeProfilesEvent)?.let { event -> - FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) { + FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { event.badgeAwardEvents().forEach { badgeAwardEvent -> val baseNote = LocalCache.notes[badgeAwardEvent] if (baseNote != null) { diff --git a/build.gradle b/build.gradle index e93e47bce..503c6fd89 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.7" lifecycle_version = '2.6.1' - compose_ui_version = '1.5.0-alpha03' + compose_ui_version = '1.4.2' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' From 6b3e10ed2844d75ec3faec793f1ad292de122685 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 17:37:23 -0400 Subject: [PATCH 24/35] Version 0.37.3 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ebc2cd388..0e19a3b58 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 129 - versionName "0.37.2" + versionCode 130 + versionName "0.37.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 8ca63f32f45e6b227b2a993eb92fe1115251acb6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 18:20:04 -0400 Subject: [PATCH 25/35] Avoids flickering in the Global Feed --- .../java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index f17d5ade5..aa37f11ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -45,6 +45,6 @@ object GlobalFeedFilter : AdditiveFeedFilter() { } override fun sort(collection: Set): List { - return collection.sortedBy { it.createdAt() }.reversed() + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() } } From f173f3194bedd4e6ab418f0acb04b057553d9dc9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 18:27:56 -0400 Subject: [PATCH 26/35] Adjusts size of the Zap Forward text. --- .../java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9fea53e30..e6fe1e9a4 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 @@ -467,7 +467,8 @@ private fun ForwardZapTo( placeholder = { Text( text = stringResource(R.string.zap_forward_lnAddress), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 14.sp ) }, colors = TextFieldDefaults From d1dc06a467d3f5e5e1849585a9fd50380ed99d33 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 18:29:04 -0400 Subject: [PATCH 27/35] v0.37.4 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0e19a3b58..394c1b6ec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 130 - versionName "0.37.3" + versionCode 131 + versionName "0.37.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 2a552bea4d5c04f0c35f69c1d4e0dbb1ef852081 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 27 Apr 2023 19:30:20 -0400 Subject: [PATCH 28/35] Update PRIVACY.md Clarifying that the PRIVACY policy covers the distribution usage and not the source code usage. --- PRIVACY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 5baf61621..7c481a0c4 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,12 +1,12 @@ # Amethyst Privacy Policy & Terms of Use -Effective as of Jan 20, 2023. +Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them. Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app. -You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. +You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling. We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy. From 1496f012a525791e30896b4f177beee674667140 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 28 Apr 2023 18:40:12 -0400 Subject: [PATCH 29/35] Adds new Highlight event kind from https://highlighter.com/ --- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../amethyst/model/LocalCache.kt | 46 +++++++++-- .../amethyst/service/NostrDataSource.kt | 7 +- .../amethyst/service/NostrGlobalDataSource.kt | 3 +- .../amethyst/service/NostrHomeDataSource.kt | 5 +- .../NostrSearchEventOrUserDataSource.kt | 2 +- .../service/NostrSingleEventDataSource.kt | 6 +- .../service/NostrUserProfileDataSource.kt | 2 +- .../amethyst/service/model/Event.kt | 3 + .../amethyst/service/model/HighlightEvent.kt | 36 +++++++++ .../amethyst/ui/components/RichTextViewer.kt | 3 +- .../ui/dal/HomeNewThreadFeedFilter.kt | 3 +- .../amethyst/ui/note/NoteCompose.kt | 81 +++++++++++++++++++ .../amethyst/ui/screen/ThreadFeedView.kt | 50 +++++++----- 14 files changed, 213 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt 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 2a0a11662..d80373eaf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -396,10 +396,10 @@ class Account( ) Client.send(data) - LocalCache.consume(data) + LocalCache.consume(data, null) Client.send(signedEvent) - LocalCache.consume(signedEvent) + LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] } @@ -418,7 +418,7 @@ class Account( ) Client.send(signedEvent) - LocalCache.consume(signedEvent) + LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] } 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 d2e76ffab..a368fa75b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -653,40 +653,72 @@ object LocalCache { refreshObservers(note) } - fun consume(event: FileHeaderEvent) { + fun consume(event: FileHeaderEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return - val author = getOrCreateUser(event.pubKey) - note.loadEvent(event, author, emptyList()) refreshObservers(note) } - fun consume(event: FileStorageHeaderEvent) { + fun consume(event: FileStorageHeaderEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // 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) { + fun consume(event: HighlightEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return + note.loadEvent(event, author, emptyList()) + + // Adds to user profile + author.addNote(note) + + refreshObservers(note) + } + + fun consume(event: FileStorageEvent, relay: Relay?) { + val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + note.loadEvent(event, author, emptyList()) refreshObservers(note) 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 b5789a178..8335a7177 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -76,9 +76,10 @@ abstract class NostrDataSource(val debugName: String) { is ContactListEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event) - is FileHeaderEvent -> LocalCache.consume(event) - is FileStorageEvent -> LocalCache.consume(event) - is FileStorageHeaderEvent -> LocalCache.consume(event) + is FileHeaderEvent -> LocalCache.consume(event, relay) + is FileStorageEvent -> LocalCache.consume(event, relay) + is FileStorageHeaderEvent -> LocalCache.consume(event, relay) + is HighlightEvent -> LocalCache.consume(event, relay) is LnZapEvent -> { event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index dbcb987e6..ebf3425db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -12,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") { fun createGlobalFilter() = TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), limit = 200 ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index b07eab060..5f855bc7c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -55,7 +56,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = followSet, limit = 400, since = latestEOSEs.users[account.userProfile()]?.relayList @@ -71,7 +72,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 29ba72306..a7262a2f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -60,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind), search = mySearchString, limit = 20 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 85746e500..2e3123c31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -79,7 +79,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - PollNoteEvent.kind + PollNoteEvent.kind, + HighlightEvent.kind ), tags = mapOf("e" to listOf(it.idHex)), since = it.lastReactionsDownloadTime @@ -117,7 +118,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, PrivateDmEvent.kind, - FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind + FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind, + HighlightEvent.kind ), ids = interestedEvents.toList() ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 19671718a..23e2196e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -35,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = listOf(it.pubkeyHex), limit = 200 ) 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 33c09bbe5..508f36487 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 @@ -41,6 +41,8 @@ open class Event( fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1) fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { @@ -227,6 +229,7 @@ open class Event( 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) + HighlightEvent.kind -> HighlightEvent(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/HighlightEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt new file mode 100644 index 000000000..1d3d1acf6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt @@ -0,0 +1,36 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class HighlightEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { + + fun inUrl() = taggedUrls().firstOrNull() + fun author() = taggedUsers().firstOrNull() + fun quote() = content + + companion object { + const val kind = 9802 + + fun create( + msg: String, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): PollNoteEvent { + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = mutableListOf>() + val id = generateId(pubKey, createdAt, kind, tags, msg) + val sig = Utils.sign(id, privateKey) + return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 25e05d8eb..2cdac42f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -86,7 +86,8 @@ fun RichTextViewer( navController: NavController ) { Column(modifier = modifier) { - if (content.startsWith("# ") || + if (content.startsWith("> ") || + content.startsWith("# ") || content.contains("##") || content.contains("**") || content.contains("__") || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index d31a45c68..b5f62086e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent @@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { return collection .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true && 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 0f2237fd5..6732bd98f 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 @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.math.BigDecimal +import java.net.URL import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -517,6 +518,26 @@ fun NoteComposeInner( ReactionsRow(note, accountViewModel, navController) } + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } else if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + + if (!makeItShort) { + ReactionsRow(note, accountViewModel, navController) + } + Divider( modifier = Modifier.padding(top = 10.dp), thickness = 0.25.dp @@ -574,6 +595,66 @@ fun NoteComposeInner( } } +@Composable +fun DisplayHighlight( + highlight: String, + authorHex: String?, + url: String?, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n") + + if (quote != null) { + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + Modifier.fillMaxWidth(), + emptyList(), + backgroundColor, + accountViewModel, + navController + ) + } + + FlowRow() { + authorHex?.let { authorHex -> + val userBase = LocalCache.checkGetOrCreateUser(authorHex) + + if (userBase != null) { + val userState by userBase.live().metadata.observeAsState() + val user = userState?.user + + if (user != null) { + CreateClickableText( + user.toBestDisplayName(), + "", + "User/${user.pubkeyHex}", + navController + ) + } + } + } + + url?.let { url -> + val validatedUrl = try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null + } + + validatedUrl?.host?.let { host -> + Text("on ") + ClickableUrl(urlText = host, url = url) + } + } + } +} + @Composable fun DisplayFollowingHashtagsInPost( noteEvent: EventInterface, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 2b3a6b466..045ce13d8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -54,6 +54,7 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status @@ -348,33 +349,46 @@ fun NoteMaster( ) ) { Column() { - val eventContent = note.event?.content() - - val canPreview = note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - if (eventContent != null) { - TranslatableRichTextViewer( - eventContent, - canPreview, - Modifier.fillMaxWidth(), - note.event?.tags(), - MaterialTheme.colors.background, + if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + false, + true, + backgroundColor, accountViewModel, navController ) + } else { + val eventContent = note.event?.content() - DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + val canPreview = note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() - if (noteEvent is PollNoteEvent) { - PollNote( - note, + if (eventContent != null) { + TranslatableRichTextViewer( + eventContent, canPreview, - backgroundColor, + Modifier.fillMaxWidth(), + note.event?.tags(), + MaterialTheme.colors.background, accountViewModel, navController ) + + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + + if (noteEvent is PollNoteEvent) { + PollNote( + note, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } } } From 571ff0e72badda4a838c5177bf84947916bfb3f4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 28 Apr 2023 18:42:44 -0400 Subject: [PATCH 30/35] v0.38.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 394c1b6ec..3b8cf318c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 131 - versionName "0.37.4" + versionCode 132 + versionName "0.38.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 206935289ed25b06cde442c3df4fc13cff1f9ef0 Mon Sep 17 00:00:00 2001 From: Zoltan <34719275+ZsZolee@users.noreply.github.com> Date: Sat, 29 Apr 2023 10:45:16 +0200 Subject: [PATCH 31/35] Upload server Hungarian translation --- app/src/main/res/values-hu/strings.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ab2e4a80a..c47fda57e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -315,4 +315,23 @@ Nem Zap Nostr-ban nyoma sincs, csak a Lightning-ben + + Fájl Szerver + LnCím vagy @Felhasználó + + imgur.com - kevésbé biztonságos + Feltöltés az Imgur-ba. Az Imgur a képet/videót bármikor megváltoztathatja + + nostrimg.com - kevésbé biztonságos + Feltöltés a NostrImg-be. A NostrImg a képet bármikor megváltoztathatja + + Ellenőrizhető Imgur (NIP-94) + Megvéd attól, hogy az Imgur a képet/videót a közzététel után megváltoztassa + + Ellenőrizhető NostrImg (NIP-94) + Megvéd attól, hogy az NostrImg a képet a közzététel után megváltoztassa + + Saját csomópontjaid (NIP-95) + A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel + From 8209c453030ec5e984a390358ac66cb57256703f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:28:46 -0400 Subject: [PATCH 32/35] New Video Feed based on NIP-94 and 95 --- .../amethyst/service/NostrVideoDataSource.kt | 23 ++ .../amethyst/ui/actions/NewMediaModel.kt | 145 +++++++ .../amethyst/ui/actions/NewMediaView.kt | 226 +++++++++++ .../ui/components/ZoomableContentView.kt | 2 +- .../amethyst/ui/dal/VideoFeedFilter.kt | 40 ++ .../amethyst/ui/navigation/AppBottomBar.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 24 ++ .../amethyst/ui/navigation/Routes.kt | 6 + .../amethyst/ui/note/ReactionsRow.kt | 50 +-- .../amethyst/ui/screen/FeedViewModel.kt | 2 + .../amethyst/ui/screen/loggedIn/MainScreen.kt | 38 +- .../ui/screen/loggedIn/VideoScreen.kt | 356 ++++++++++++++++++ app/src/main/res/drawable/ic_video.xml | 9 + 13 files changed, 875 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt create mode 100644 app/src/main/res/drawable/ic_video.xml diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt new file mode 100644 index 000000000..f35fe3cf6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -0,0 +1,23 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.service.model.FileHeaderEvent +import com.vitorpamplona.amethyst.service.model.FileStorageEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +object NostrVideoDataSource : NostrDataSource("VideoFeed") { + fun createGlobalFilter() = TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(FileHeaderEvent.kind, FileStorageEvent.kind), + limit = 200 + ) + ) + + val videoFeedChannel = requestNewChannel() + + override fun updateChannelFilters() { + videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt new file mode 100644 index 000000000..2c4a89999 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -0,0 +1,145 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.FileHeader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +open class NewMediaModel : ViewModel() { + var account: Account? = null + + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = MutableSharedFlow() + var mediaType by mutableStateOf(null) + + var selectedServer by mutableStateOf(null) + var description by mutableStateOf("") + + // Images and Videos + var galleryUri by mutableStateOf(null) + + open fun load(account: Account, uri: Uri, contentType: String?) { + this.account = account + this.galleryUri = uri + this.mediaType = contentType + this.selectedServer = defaultServer() + + if (selectedServer == ServersAvailable.IMGUR) { + selectedServer = ServersAvailable.IMGUR_NIP_94 + } else if (selectedServer == ServersAvailable.NOSTRIMG) { + selectedServer = ServersAvailable.NOSTRIMG_NIP_94 + } + } + + fun upload(context: Context, onClose: () -> Unit) { + isUploadingImage = true + + val contentResolver = context.contentResolver + val uri = galleryUri ?: return + val serverToUse = selectedServer ?: return + + if (selectedServer == ServersAvailable.NIP95) { + val contentType = contentResolver.getType(uri) + contentResolver.openInputStream(uri)?.use { + createNIP95Record(it.readBytes(), contentType, description, onClose) + } + ?: viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } else { + ImageUploader.uploadImage( + uri = uri, + server = serverToUse, + contentResolver = contentResolver, + onSuccess = { imageUrl, mimeType -> + createNIP94Record(imageUrl, mimeType, description, onClose) + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + open fun cancel() { + galleryUri = null + isUploadingImage = false + mediaType = null + + description = "" + selectedServer = account?.defaultFileServer + } + + fun canPost(): Boolean { + return !isUploadingImage && galleryUri != null && selectedServer != null + } + + fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + description, + onReady = { + val note = account?.sendHeader(it) + isUploadingImage = false + cancel() + onClose() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + "", + mimeType, + description, + onReady = { + account?.sendNip95(bytes, headerInfo = it) + isUploadingImage = false + cancel() + onClose() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + fun isImage() = mediaType?.startsWith("image") + fun isVideo() = mediaType?.startsWith("video") + fun defaultServer() = account?.defaultFileServer +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt new file mode 100644 index 000000000..b50b1843e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -0,0 +1,226 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.util.Size +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.* +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) { + val account = accountViewModel.accountLiveData.value?.account ?: return + val resolver = LocalContext.current.contentResolver + val postViewModel: NewMediaModel = viewModel() + val context = LocalContext.current + + val scroolState = rememberScrollState() + + LaunchedEffect(Unit) { + val mediaType = resolver.getType(uri) ?: "" + postViewModel.load(account, uri, mediaType) + delay(100) + + postViewModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp) + .fillMaxWidth() + .fillMaxHeight().imePadding() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + if (postViewModel.isUploadingImage) { + LoadingAnimation() + } + + PostButton( + onPost = { + postViewModel.upload(context) { + onClose() + } + }, + isActive = postViewModel.canPost() + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scroolState) + ) { + ImageVideoPost(postViewModel) + } + } + } + } + } +} + +@Composable +fun ImageVideoPost(postViewModel: NewMediaModel) { + val scope = rememberCoroutineScope() + + val fileServers = listOf( + Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), + Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), + Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) + ) + + val fileServerOptions = fileServers.map { it.second } + val fileServerExplainers = fileServers.map { it.third } + val resolver = LocalContext.current.contentResolver + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + if (postViewModel.isImage() == true) { + AsyncImage( + model = postViewModel.galleryUri.toString(), + contentDescription = postViewModel.galleryUri.toString(), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) + } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = postViewModel.galleryUri) { + scope.launch(Dispatchers.IO) { + postViewModel.galleryUri?.let { + bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null) + } + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } + } else { + postViewModel.galleryUri?.let { + VideoView(it) + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + TextSpinner( + label = stringResource(id = R.string.file_server), + placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second, + options = fileServerOptions, + explainers = fileServerExplainers, + onSelect = { + postViewModel.selectedServer = fileServers[it].first + }, + modifier = Modifier + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + .weight(1f) + ) + } + + if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || + postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 || + postViewModel.selectedServer == ServersAvailable.NIP95 + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = postViewModel.description, + onValueChange = { postViewModel.description = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index bb31eeb9e..775851211 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -406,7 +406,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List() { + lateinit var account: Account + + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val now = System.currentTimeMillis() / 1000 + + return collection + .asSequence() + .filter { + it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent + } + .filter { account.isAcceptable(it) } + .filter { + // Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. + it.createdAt()!! <= now + } + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index db0ab3644..95048de58 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime val bottomNavigationItems = listOf( Route.Home, Route.Message, + Route.Video, Route.Search, Route.Notification ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fc655954e..512e6c8e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -17,9 +17,11 @@ import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter +import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen @@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen @OptIn(ExperimentalFoundationApi::class) @Composable @@ -58,10 +61,31 @@ fun AppNavigation( GlobalFeedFilter.account = account val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel() + VideoFeedFilter.account = account + val videoFeedViewModel: NostrVideoFeedViewModel = viewModel() + NotificationFeedFilter.account = account val notifFeedViewModel: NotificationViewModel = viewModel() NavHost(navController, startDestination = Route.Home.route) { + Route.Video.let { route -> + composable(route.route, route.arguments, content = { + val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false + + VideoScreen( + videoFeedView = videoFeedViewModel, + accountViewModel = accountViewModel, + navController = navController, + scrollToTop = scrollToTop + ) + + // Avoids running scroll to top when back button is pressed + if (scrollToTop) { + it.arguments?.remove("scrollToTop") + } + }) + } + Route.Search.let { route -> composable(route.route, route.arguments, content = { val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index f0266f41a..a123dc0c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -41,6 +41,12 @@ sealed class Route( arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }) ) + object Video : Route( + route = "Video?scrollToTop={scrollToTop}", + icon = R.drawable.ic_video, + arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }) + ) + object Notification : Route( route = "Notification?scrollToTop={scrollToTop}", icon = R.drawable.ic_notifications, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 4b704985d..27c377036 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -116,6 +117,7 @@ fun ReplyReaction( baseNote: Note, accountViewModel: AccountViewModel, showCounter: Boolean = true, + iconSize: Dp = 20.dp, onPress: () -> Unit ) { val repliesState by baseNote.live().replies.observeAsState() @@ -125,7 +127,7 @@ fun ReplyReaction( val scope = rememberCoroutineScope() IconButton( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), onClick = { if (accountViewModel.isWriteable()) { onPress() @@ -140,7 +142,7 @@ fun ReplyReaction( } } ) { - ReplyIcon() + ReplyIcon(iconSize) } if (showCounter) { @@ -153,19 +155,20 @@ fun ReplyReaction( } @Composable -private fun ReplyIcon() { +private fun ReplyIcon(iconSize: Dp = 15.dp) { Icon( painter = painterResource(R.drawable.ic_comment), null, - modifier = Modifier.size(15.dp), + modifier = Modifier.size(iconSize), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @Composable -private fun BoostReaction( +public fun BoostReaction( baseNote: Note, accountViewModel: AccountViewModel, + iconSize: Dp = 20.dp, onQuotePress: () -> Unit ) { val boostsState by baseNote.live().boosts.observeAsState() @@ -178,7 +181,7 @@ private fun BoostReaction( var wantsToBoost by remember { mutableStateOf(false) } IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), + modifier = Modifier.then(Modifier.size(iconSize)), onClick = { if (accountViewModel.isWriteable()) { if (accountViewModel.hasBoosted(baseNote)) { @@ -215,14 +218,14 @@ private fun BoostReaction( Icon( painter = painterResource(R.drawable.ic_retweeted), null, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = Color.Unspecified ) } else { Icon( painter = painterResource(R.drawable.ic_retweet), null, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = grayTint ) } @@ -238,7 +241,9 @@ private fun BoostReaction( @Composable fun LikeReaction( baseNote: Note, - accountViewModel: AccountViewModel + accountViewModel: AccountViewModel, + iconSize: Dp = 20.dp, + heartSize: Dp = 16.dp ) { val reactionsState by baseNote.live().reactions.observeAsState() val reactedNote = reactionsState?.note ?: return @@ -248,7 +253,7 @@ fun LikeReaction( val scope = rememberCoroutineScope() IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), + modifier = Modifier.then(Modifier.size(iconSize)), onClick = { if (accountViewModel.isWriteable()) { if (accountViewModel.hasReactedTo(baseNote)) { @@ -271,14 +276,14 @@ fun LikeReaction( Icon( painter = painterResource(R.drawable.ic_liked), null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(heartSize), tint = Color.Unspecified ) } else { Icon( painter = painterResource(R.drawable.ic_like), null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(heartSize), tint = grayTint ) } @@ -296,7 +301,9 @@ fun LikeReaction( fun ZapReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier + textModifier: Modifier = Modifier, + iconSize: Dp = 20.dp, + animationSize: Dp = 14.dp ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -326,8 +333,7 @@ fun ZapReaction( Row( verticalAlignment = CenterVertically, - modifier = Modifier - .then(Modifier.size(20.dp)) + modifier = Modifier.size(iconSize) .combinedClickable( role = Role.Button, interactionSource = remember { MutableInteractionSource() }, @@ -427,7 +433,7 @@ fun ZapReaction( Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = BitcoinOrange ) } else { @@ -435,14 +441,14 @@ fun ZapReaction( Icon( imageVector = Icons.Outlined.Bolt, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = grayTint ) } else { Spacer(Modifier.width(3.dp)) CircularProgressIndicator( progress = zappingProgress, - modifier = Modifier.size(14.dp), + modifier = Modifier.size(animationSize), strokeWidth = 2.dp ) } @@ -466,18 +472,18 @@ fun ZapReaction( } @Composable -private fun ViewCountReaction(idHex: String) { +public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) { val uri = LocalUriHandler.current val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) IconButton( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") } ) { Icon( imageVector = Icons.Outlined.BarChart, null, - modifier = Modifier.size(19.dp), + modifier = Modifier.size(barChartSize), tint = grayTint ) } @@ -490,7 +496,7 @@ private fun ViewCountReaction(idHex: String) { .memoryCachePolicy(CachePolicy.ENABLED) .build(), contentDescription = stringResource(R.string.view_count), - modifier = Modifier.height(24.dp), + modifier = Modifier.height(numberSize), colorFilter = ColorFilter.tint(grayTint) ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 60daf1072..1290d4ed4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter +import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -34,6 +35,7 @@ import kotlinx.coroutines.launch class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter) class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter) class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter) +class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter) class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter) class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter) class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 0adfec2b0..937332cdc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -88,34 +88,24 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) { val accountState by accountStateViewModel.accountContent.collectAsState() - if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { - Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is AccountState.LoggedInViewOnly -> { - // Does nothing. - } - is AccountState.LoggedOff -> { - // Does nothing. - } - is AccountState.LoggedIn -> { + Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is AccountState.LoggedInViewOnly -> { + // Does nothing. + } + is AccountState.LoggedOff -> { + // Does nothing. + } + is AccountState.LoggedIn -> { + if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { NewNoteButton(state.account, accountViewModel, navController) } - } - } - } - - if (currentRoute(navController) == Route.Message.base) { - Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is AccountState.LoggedInViewOnly -> { - // Does nothing. - } - is AccountState.LoggedOff -> { - // Does nothing. - } - is AccountState.LoggedIn -> { + if (currentRoute(navController) == Route.Message.base) { NewChannelButton(state.account) } + if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) { + NewImageButton(accountViewModel, navController) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt new file mode 100644 index 000000000..70106f2f0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -0,0 +1,356 @@ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.Manifest +import android.net.Uri +import android.os.Build +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.NostrVideoDataSource +import com.vitorpamplona.amethyst.service.model.FileHeaderEvent +import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent +import com.vitorpamplona.amethyst.ui.actions.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.NewMediaView +import com.vitorpamplona.amethyst.ui.actions.NewPostView +import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ViewCountReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.screen.FeedEmpty +import com.vitorpamplona.amethyst.ui.screen.FeedError +import com.vitorpamplona.amethyst.ui.screen.FeedState +import com.vitorpamplona.amethyst.ui.screen.LoadingFeed +import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel + +@Composable +fun VideoScreen( + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController, + scrollToTop: Boolean = false +) { + val lifeCycleOwner = LocalLifecycleOwner.current + val account = accountViewModel.accountLiveData.value?.account ?: return + + VideoFeedFilter.account = account + + LaunchedEffect(accountViewModel) { + VideoFeedFilter.account = account + NostrVideoDataSource.resetFilters() + videoFeedView.invalidateData() + } + + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Video Start") + VideoFeedFilter.account = account + NostrVideoDataSource.start() + videoFeedView.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Video Stop") + NostrVideoDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + FeedView(videoFeedView, accountViewModel, navController) + } + } +} + +@Composable +fun FeedView( + videoFeedView: NostrVideoFeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController +) { + val feedState by videoFeedView.feedContent.collectAsState() + + Box() { + Column { + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100) + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty {} + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage) {} + } + + is FeedState.Loaded -> { + SlidingCarousel( + state.feed, + accountViewModel, + navController + ) + } + + is FeedState.Loading -> { + LoadingFeed() + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SlidingCarousel( + feed: MutableState>, + accountViewModel: AccountViewModel, + navController: NavController +) { + val pagerState: PagerState = remember { PagerState() } + + VerticalPager( + pageCount = feed.value.size, + state = pagerState, + beyondBoundsPageCount = 1, + modifier = Modifier.fillMaxSize(1f) + ) { index -> + feed.value.getOrNull(index)?.let { note -> + RenderVideoOrPictureNote(note, accountViewModel, navController) + } + } +} + +@Composable +private fun RenderVideoOrPictureNote( + note: Note, + accountViewModel: AccountViewModel, + navController: NavController +) { + val noteEvent = note.event + + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + val loggedIn = account.userProfile() + + var moreActionsExpanded by remember { mutableStateOf(false) } + + Column(Modifier.fillMaxSize(1f)) { + Row(Modifier.weight(1f, true), verticalAlignment = Alignment.CenterVertically) { + if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note) + } + } + } + + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) { + Column(Modifier.weight(1f)) { + Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) { + Column(Modifier.size(45.dp), verticalArrangement = Arrangement.Center) { + NoteAuthorPicture(note, navController, loggedIn, 45.dp) + } + + Column( + Modifier + .padding(start = 10.dp, end = 10.dp) + .height(45.dp) + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NoteUsernameDisplay(note, Modifier.weight(1f)) + + IconButton( + modifier = Modifier.size(24.dp), + onClick = { moreActionsExpanded = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f)) + } + } + } + } + + Column( + Modifier + .width(65.dp) + .padding(bottom = 10.dp), + verticalArrangement = Arrangement.Center + ) { + Row(horizontalArrangement = Arrangement.Center) { + ReactionsColumn(note, accountViewModel, navController) + } + } + } +} + +@Composable +fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + var wantsToReplyTo by remember { + mutableStateOf(null) + } + + var wantsToQuote by remember { + mutableStateOf(null) + } + + if (wantsToReplyTo != null) { + NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController) + } + + if (wantsToQuote != null) { + NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) { + /* + ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) { + wantsToReplyTo = baseNote + } + BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) { + wantsToQuote = baseNote + }*/ + LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp) + ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp) + ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NewImageButton(accountViewModel: AccountViewModel, navController: NavController) { + var wantsToPost by remember { + mutableStateOf(false) + } + + var pickedURI by remember { + mutableStateOf(null) + } + + if (wantsToPost) { + val cameraPermissionState = + rememberPermissionState( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + ) + + if (cameraPermissionState.status.isGranted) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + wantsToPost = false + showGallerySelect = false + pickedURI = uri + } + ) + } + + showGallerySelect = true + } else { + LaunchedEffect(key1 = accountViewModel) { + cameraPermissionState.launchPermissionRequest() + } + } + } + + pickedURI?.let { + NewMediaView(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController) + } + + OutlinedButton( + onClick = { wantsToPost = true }, + modifier = Modifier.size(55.dp), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White + ) + } +} diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml new file mode 100644 index 000000000..124419fa2 --- /dev/null +++ b/app/src/main/res/drawable/ic_video.xml @@ -0,0 +1,9 @@ + + + From ab330fea9693ba940643e52e465d7d604718cf45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:29:42 -0400 Subject: [PATCH 33/35] v0.39.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3b8cf318c..f48bbafc0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 132 - versionName "0.38.0" + versionCode 133 + versionName "0.39.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 8b012884b1fdec773f7f3cdc538a4de72b9bfca9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:31:00 -0400 Subject: [PATCH 34/35] 0.39.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3b8cf318c..f48bbafc0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 132 - versionName "0.38.0" + versionCode 133 + versionName "0.39.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 635ed1afc1a2b8f3ec08c20e1c4418db89885820 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:45:27 -0400 Subject: [PATCH 35/35] v0.40.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f48bbafc0..9f1b014a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 133 - versionName "0.39.0" + versionCode 134 + versionName "0.40.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {