From 8040608e1450802239dcaad068961dc9d86c1655 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 10:21:24 -0400 Subject: [PATCH 1/9] Fixes: https://github.com/vitorpamplona/amethyst/issues/359 --- .../java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index d4fd4765e..97775bfb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -22,7 +22,7 @@ object HashtagFeedFilter : AdditiveFeedFilter() { } override fun applyFilter(collection: Set): Set { - return applyFilter(collection) + return innerApplyFilter(collection) } private fun innerApplyFilter(collection: Collection): Set { From 3f579793ca03df1293b1a03df44e5513e5bdd23f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 10:21:50 -0400 Subject: [PATCH 2/9] v0.33.2 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e334d8e6f..527fad218 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 120 - versionName "0.33.1" + versionCode 121 + versionName "0.33.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 1adb2b2caa1d2c76016cccddf2c83f8c283df607 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 11:00:06 -0400 Subject: [PATCH 3/9] Fix crashing when changing users. --- .../amethyst/ui/navigation/Routes.kt | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) 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 216a5531f..f0266f41a 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 @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter @@ -109,9 +110,29 @@ fun currentRoute(navController: NavHostController): String? { return navBackStackEntry?.destination?.route } -object HomeLatestItem { - private var newestItem: Note? = null +open class LatestItem { + var newestItemPerAccount: Map = mapOf() + fun updateNewestItem(newNotes: Set, account: Account, filter: AdditiveFeedFilter): Note? { + val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] + + if (newestItem == null) { + newestItemPerAccount = newestItemPerAccount + Pair( + account.userProfile().pubkeyHex, + filter.feed().firstOrNull { it.createdAt() != null } + ) + } else { + newestItemPerAccount = newestItemPerAccount + Pair( + account.userProfile().pubkeyHex, + filter.sort(filter.applyFilter(newNotes) + newestItem).first() + ) + } + + return newestItemPerAccount[account.userProfile().pubkeyHex] + } +} + +object HomeLatestItem : LatestItem() { fun hasNewItems( account: Account, cache: NotificationCache, @@ -120,22 +141,13 @@ object HomeLatestItem { val lastTime = cache.load("HomeFollows") HomeNewThreadFeedFilter.account = account - if (newestItem == null) { - newestItem = HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null } - } else { - newestItem = - HomeNewThreadFeedFilter.sort( - HomeNewThreadFeedFilter.applyFilter(newNotes + newestItem!!) - ).first() - } + val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter) return (newestItem?.createdAt() ?: 0) > lastTime } } -object NotificationLatestItem { - private var newestItem: Note? = null - +object NotificationLatestItem : LatestItem() { fun hasNewItems( account: Account, cache: NotificationCache, @@ -144,21 +156,13 @@ object NotificationLatestItem { val lastTime = cache.load("Notification") NotificationFeedFilter.account = account - if (newestItem == null) { - newestItem = NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null } - } else { - newestItem = HomeNewThreadFeedFilter.sort( - NotificationFeedFilter.applyFilter(newNotes) + newestItem!! - ).first() - } + val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter) return (newestItem?.createdAt() ?: 0) > lastTime } } object MessagesLatestItem { - private var newestItem: Note? = null - fun hasNewItems( account: Account, cache: NotificationCache, From 783204b57f47b9dd348e39f1d8598f06e7f02889 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 17:01:42 -0400 Subject: [PATCH 4/9] Support for NIP-94 --- app/build.gradle | 3 + .../vitorpamplona/amethyst/model/Account.kt | 20 + .../amethyst/model/LocalCache.kt | 13 + .../amethyst/service/BlurHashDecoder.kt | 203 +++++++++ .../amethyst/service/BlurHashImage.kt | 65 +++ .../amethyst/service/FileHeader.kt | 65 +++ .../amethyst/service/NostrDataSource.kt | 1 + .../service/NostrSingleEventDataSource.kt | 9 +- .../amethyst/service/model/Event.kt | 1 + .../amethyst/service/model/FileHeaderEvent.kt | 71 +++ .../amethyst/ui/actions/ImageUploader.kt | 4 +- .../amethyst/ui/actions/NewPostViewModel.kt | 39 +- .../ui/actions/NewUserMetadataViewModel.kt | 2 +- .../amethyst/ui/components/RichTextViewer.kt | 19 +- .../ui/components/ZoomableContentView.kt | 408 ++++++++++++++++++ .../ui/components/ZoomableImageView.kt | 188 -------- .../amethyst/ui/note/NoteCompose.kt | 26 ++ .../ui/screen/loggedIn/ChannelScreen.kt | 2 + .../ui/screen/loggedIn/ChatroomScreen.kt | 2 + .../ui/screen/loggedIn/ProfileScreen.kt | 8 +- app/src/main/res/values/strings.xml | 5 + 21 files changed, 945 insertions(+), 209 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt delete mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt diff --git a/app/build.gradle b/app/build.gradle index 527fad218..db60144bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -146,6 +146,9 @@ dependencies { // view svgs implementation "io.coil-kt:coil-svg:$coil_version" + // create blurhash + implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0' + // Rendering clickable text implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" // Permission to upload pictures: 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 8a0a85a82..df4c61fb7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants @@ -357,6 +358,25 @@ class Account( } } + fun sendHeader(headerInfo: FileHeader): Note? { + if (!isWriteable()) return null + + val signedEvent = FileHeaderEvent.create( + url = headerInfo.url, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + blurhash = headerInfo.blurHash, + description = headerInfo.description, + privateKey = loggedIn.privKey!! + ) + + Client.send(signedEvent) + LocalCache.consume(signedEvent) + + return LocalCache.notes[signedEvent.id] + } + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { if (!isWriteable()) return 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 41539afa1..11316a775 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -651,6 +651,19 @@ object LocalCache { refreshObservers(note) } + fun consume(event: FileHeaderEvent) { + 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 findUsersStartingWith(username: String): List { return users.values.filter { (it.anyNameStartsWith(username)) || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt new file mode 100644 index 000000000..02f980056 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt @@ -0,0 +1,203 @@ +package com.vitorpamplona.amethyst.service + +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +object BlurHashDecoder { + + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed + private val cacheCosinesX = HashMap() + private val cacheCosinesY = HashMap() + + /** + * Clear calculations stored in memory cache. + * The cache is not big, but will increase when many image sizes are used, + * if the app needs memory it is recommended to clear it. + */ + fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + /** + * Returns width/height + */ + fun aspectRatio(blurHash: String?): Float? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + + return numCompX.toFloat() / numCompY.toFloat() + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. + * By default it is true. + */ + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { + calculate -> { + DoubleArray(height * numCompY).also { + cacheCosinesY[height * numCompY] = it + } + } + else -> { + cacheCosinesY[height * numCompY]!! + } + } + + private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { + calculate -> { + DoubleArray(width * numCompX).also { + cacheCosinesX[width * numCompX] = it + } + } + else -> cacheCosinesX[width * numCompX]!! + } + + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt new file mode 100644 index 000000000..b94f35e91 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -0,0 +1,65 @@ +package com.vitorpamplona.amethyst.service + +import android.content.Context +import android.net.Uri +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.decode.DataSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.request.ImageRequest +import coil.request.Options +import java.net.URLDecoder +import java.net.URLEncoder +import kotlin.math.roundToInt + +class BlurHashFetcher( + private val options: Options, + private val data: Uri +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val encodedHash = data.toString().removePrefix("bluehash:") + val hash = URLDecoder.decode(encodedHash, "utf-8") + + val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f + + val preferredWidth = 100 + + val bitmap = BlurHashDecoder.decode( + hash, + preferredWidth, + (preferredWidth * (1 / aspectRatio)).roundToInt() + ) + + if (bitmap == null) { + throw Exception("Unable to convert Bluehash $hash") + } + + return DrawableResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.MEMORY + ) + } + + object Factory : Fetcher.Factory { + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { + return BlurHashFetcher(options, data) + } + } +} + +object BlurHashRequester { + fun imageRequest(context: Context, message: String): ImageRequest { + val encodedMessage = URLEncoder.encode(message, "utf-8") + + return ImageRequest + .Builder(context) + .data("bluehash:$encodedMessage") + .fetcherFactory(BlurHashFetcher.Factory) + .crossfade(100) + .build() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt new file mode 100644 index 000000000..a7543e56f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -0,0 +1,65 @@ +package com.vitorpamplona.amethyst.service + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.vitorpamplona.amethyst.model.toHexKey +import io.trbl.blurhash.BlurHash +import java.net.URL +import java.security.MessageDigest +import kotlin.math.roundToInt + +class FileHeader( + val url: String, + val mimeType: String?, + val hash: String, + val size: Int, + val blurHash: String?, + val description: String? = null +) { + companion object { + fun prepare(fileUrl: String, mimeType: 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 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 intArray = IntArray(mBitmap.width * mBitmap.height) + mBitmap.getPixels( + intArray, + 0, + mBitmap.width, + 0, + 0, + mBitmap.width, + mBitmap.height + ) + + val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat() + + if (aspectRatio > 1) { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()) + } else if (aspectRatio < 1) { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9) + } else { + BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4) + } + } else { + null + } + + onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, "")) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") + onError() + } + } + } +} 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 6d4fbadf9..61fa8bedf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -75,6 +75,7 @@ abstract class NostrDataSource(val debugName: String) { is ContactListEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event) + is FileHeaderEvent -> 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/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index aa4e87eb5..9a6a6568a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -111,9 +111,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { types = FeedType.values().toSet(), filter = JsonFilter( kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, - PollNoteEvent.kind, PrivateDmEvent.kind + TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, + ReactionEvent.kind, RepostEvent.kind, + LnZapEvent.kind, LnZapRequestEvent.kind, + ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, + BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, + PrivateDmEvent.kind, FileHeaderEvent.kind ), ids = interestedEvents.toList() ) 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 818a2d3b2..6d857328c 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 @@ -222,6 +222,7 @@ open class Event( ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) 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 new file mode 100644 index 000000000..53988d943 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -0,0 +1,71 @@ +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 FileHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.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) + 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 = 1063 + + private const val URL = "url" + 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( + url: String, + 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 + ): FileHeaderEvent { + var tags = listOfNotNull( + listOf(URL, url), + 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 FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} + +data class AESGCM(val key: String, val nonce: 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 574fef2b1..cf15b8c3a 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 @@ -15,7 +15,7 @@ object ImageUploader { fun uploadImage( uri: Uri, contentResolver: ContentResolver, - onSuccess: (String) -> Unit, + onSuccess: (String, String?) -> Unit, onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(uri) @@ -64,7 +64,7 @@ object ImageUploader { "There must be an uploaded image URL in the response" } - onSuccess(url) + onSuccess(url, contentType) } } catch (e: Exception) { e.printStackTrace() 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 8be28a491..5fccd7a43 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 @@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.isValidURL @@ -110,13 +111,39 @@ open class NewPostViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, - onSuccess = { imageUrl -> - isUploadingImage = false - message = TextFieldValue(message.text + "\n\n" + imageUrl) - + onSuccess = { imageUrl, mimeType -> viewModelScope.launch(Dispatchers.IO) { - delay(2000) - urlPreview = findUrlInMessage() + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + onReady = { + val note = account?.sendHeader(it) + + isUploadingImage = false + + if (note == null) { + message = TextFieldValue(message.text + "\n\n" + imageUrl) + } else { + message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) } }, onError = { 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 e6a9bf2e0..5a252ef02 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 @@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() { ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, - onSuccess = { imageUrl -> + onSuccess = { imageUrl, mimeType -> onUploading(false) onUploaded(imageUrl) }, 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 d28752b85..5a4fd7e74 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 @@ -139,11 +139,17 @@ fun RichTextViewer( } else { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } - val imagesForPager = urlSet.filter { fullUrl -> + val imagesForPager = urlSet.mapNotNull { fullUrl -> val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() - imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) } - } - val imagesForPagerSet = imagesForPager.toSet() + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableImage(fullUrl) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableVideo(fullUrl) + } else { + null + } + }.associateBy { it.url } + val imageList = imagesForPager.values.toList() // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> @@ -152,8 +158,9 @@ fun RichTextViewer( s.forEach { word: String -> if (canPreview) { // Explicit URL - if (imagesForPagerSet.contains(word)) { - ZoomableImageView(word, imagesForPager) + val img = imagesForPager[word] + if (img != null) { + ZoomableContentView(img, imageList) } else if (urlSet.contains(word)) { UrlPreview(word, "$word ") } else if (word.startsWith("lnbc", true)) { 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 new file mode 100644 index 000000000..567730af9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -0,0 +1,408 @@ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.annotation.ExperimentalCoilApi +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.imageLoader +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.BlurHashRequester +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.actions.SaveToGallery +import com.vitorpamplona.amethyst.ui.theme.Nip05 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable +import java.security.MessageDigest + +abstract class ZoomableContent( + val url: String, + val description: String? = null, + val hash: String? = null +) + +class ZoomableImage( + url: String, + description: String? = null, + hash: String? = null, + val bluehash: String? = null +) : ZoomableContent(url, description, hash) + +class ZoomableVideo( + url: String, + description: String? = null, + hash: String? = null +) : ZoomableContent(url, description, hash) + +fun figureOutMimeType(fullUrl: String): ZoomableContent { + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + return if (isImage) { + ZoomableImage(fullUrl) + } else if (isVideo) { + ZoomableVideo(fullUrl) + } else { + ZoomableImage(fullUrl) + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ZoomableContentView(content: ZoomableContent, images: List = listOf(content)) { + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // store the dialog open or close state + var dialogOpen by remember { + mutableStateOf(false) + } + + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + + var verifiedHash by remember { + mutableStateOf(null) + } + + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + + val mainImageModifier = Modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) + .combinedClickable( + onClick = { dialogOpen = true }, + onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } + ) + + if (content is ZoomableImage) { + Box() { + AsyncImage( + model = content.url, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else { + VideoView(content.url) { dialogOpen = true } + } + + if (dialogOpen) { + ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }) + } +} + +@Composable +private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { + ClickableUrl(urlText = "$content ", url = content.url) + + val myId = "inlineContent" + val emptytext = buildAnnotatedString { + withStyle( + LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() + ) { + append("") + appendInlineContent(myId, "[icon]") + } + } + val inlineContent = mapOf( + Pair( + myId, + InlineTextContent( + Placeholder( + width = 17.sp, + height = 17.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + LoadingAnimation() + } + ) + ) + + // Empty Text for Size of Icon + Text( + text = emptytext, + inlineContent = inlineContent + ) +} + +@Composable +private fun DisplayBlueHash( + content: ZoomableImage, + 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 + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + Column() { + val pagerState: PagerState = remember { PagerState() } + + Row( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = onDismiss) + + SaveToGallery(url = allImages[pagerState.currentPage].url) + } + + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + itemsCount = allImages.size, + itemContent = { index -> + RenderImageOrVideo(allImages[index]) + } + ) + } else { + RenderImageOrVideo(imageUrl) + } + } + } + } +} + +@Composable +private fun RenderImageOrVideo(content: ZoomableContent) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // store the dialog open or close state + var imageState by remember { + mutableStateOf(null) + } + + var verifiedHash by remember { + 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 ZoomableImage) { + Box() { + AsyncImage( + model = content.url, + 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 { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoView(content.url) + } + } +} + +@OptIn(ExperimentalCoilApi::class) +private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? { + if (content.hash == null) return null + + context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> + val imageFile = snapshot.data.toFile() + val bytes = imageFile.readBytes() + val sha256 = MessageDigest.getInstance("SHA-256") + + val hash = sha256.digest(bytes).toHexKey() + + Log.d("Image Hash Verification", "$hash == ${content.hash}") + + return hash == content.hash + } + + return null +} + +@Composable +private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) { + if (verifiedHash == null) return + + val localContext = LocalContext.current + val scope = rememberCoroutineScope() + + Box( + modifier + .width(40.dp) + .height(40.dp) + .padding(10.dp) + ) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colors.background) + ) + + if (verifiedHash == true) { + IconButton( + onClick = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.hash_verification_passed), + Toast.LENGTH_LONG + ).show() + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_verified), + "Hash Verified", + tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background), + modifier = Modifier.size(30.dp) + ) + } + } else if (verifiedHash == false) { + IconButton( + onClick = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.hash_verification_failed), + Toast.LENGTH_LONG + ).show() + } + } + ) { + Icon( + tint = Color.Red, + imageVector = Icons.Default.Report, + contentDescription = "Invalid Hash", + modifier = Modifier.size(30.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt deleted file mode 100644 index 20f82bfc6..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.vitorpamplona.amethyst.ui.components - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation -import com.vitorpamplona.amethyst.ui.actions.SaveToGallery -import net.engawapg.lib.zoomable.rememberZoomState -import net.engawapg.lib.zoomable.zoomable - -@Composable -@OptIn(ExperimentalFoundationApi::class) -fun ZoomableImageView(word: String, images: List = listOf(word)) { - val clipboardManager = LocalClipboardManager.current - - // store the dialog open or close state - var dialogOpen by remember { - mutableStateOf(false) - } - - // store the dialog open or close state - var imageState by remember { - mutableStateOf(null) - } - - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = word, - contentDescription = word, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ) - .combinedClickable( - onClick = { dialogOpen = true }, - onLongClick = { clipboardManager.setText(AnnotatedString(word)) } - ), - onLoading = { - imageState = it - }, - onSuccess = { - imageState = it - } - ) - - if (imageState !is AsyncImagePainter.State.Success) { - ClickableUrl(urlText = "$word ", url = word) - - val myId = "inlineContent" - val emptytext = buildAnnotatedString { - withStyle( - LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() - ) { - append("") - appendInlineContent(myId, "[icon]") - } - } - val inlineContent = mapOf( - Pair( - myId, - InlineTextContent( - Placeholder( - width = 17.sp, - height = 17.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - LoadingAnimation() - } - ) - ) - - // Empty Text for Size of Icon - Text( - text = emptytext, - inlineContent = inlineContent - ) - } - } else { - VideoView(word) { dialogOpen = true } - } - - if (dialogOpen) { - ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false }) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - Column() { - var pagerState: PagerState = remember { PagerState() } - - Row( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = onDismiss) - - SaveToGallery(url = allImages[pagerState.currentPage]) - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - itemsCount = allImages.size, - itemContent = { index -> - RenderImageOrVideo(allImages[index]) - } - ) - } else { - RenderImageOrVideo(imageUrl) - } - } - } - } -} - -@Composable -private fun RenderImageOrVideo(imageUrl: String) { - val removedParamsFromUrl = imageUrl.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - AsyncImage( - model = imageUrl, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxSize() - .zoomable(rememberZoomState()) - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoView(imageUrl) - } - } -} 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 f6d4f406c..a08a2336b 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 @@ -167,6 +167,8 @@ fun NoteComposeInner( ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) } else if (noteEvent is BadgeDefinitionEvent) { BadgeDisplay(baseNote = note) + } else if (noteEvent is FileHeaderEvent) { + FileHeaderDisplay(note) } else { var isNew by remember { mutableStateOf(false) } @@ -776,6 +778,30 @@ fun BadgeDisplay(baseNote: Note) { } } +@Composable +fun FileHeaderDisplay(note: Note) { + val event = (note.event as? FileHeaderEvent) ?: return + + val fullUrl = event.url() ?: return + val blurHash = event.blurhash() + val hash = event.hash() + val description = event.content + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + + if (isImage || isVideo) { + val content = if (isImage) { + ZoomableImage(fullUrl, description, hash, blurHash) + } else { + ZoomableVideo(fullUrl, description, hash) + } + ZoomableContentView(content = content, listOf(content)) + } else { + UrlPreview(fullUrl, "$fullUrl ") + } +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { Row( 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 32c382803..784d61262 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 @@ -89,6 +89,8 @@ fun ChannelScreen( val context = LocalContext.current val channelScreenModel: NewPostViewModel = viewModel() + channelScreenModel.account = account + if (account != null && channelId != null) { val replyTo = remember { mutableStateOf(null) } 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 0f26c18bc..07381c5e7 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 @@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr val context = LocalContext.current val chatRoomScreenModel: NewPostViewModel = viewModel() + chatRoomScreenModel.account = account + if (account != null && userId != null) { val replyTo = remember { mutableStateOf(null) } 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 77da720bc..4d9bf93cc 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 @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog +import com.vitorpamplona.amethyst.ui.components.figureOutMimeType import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter @@ -401,8 +402,9 @@ private fun ProfileHeader( } } - if (zoomImageDialogOpen) { - ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false }) + val profilePic = baseUser.profilePicture() + if (zoomImageDialogOpen && profilePic != null) { + ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false }) } } @@ -706,7 +708,7 @@ private fun DrawBanner(baseUser: User) { ) if (zoomImageDialogOpen) { - ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false }) + ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false }) } } else { Image( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1878907d..3e3109e95 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -292,4 +292,9 @@ Create and Add Poll authors can\'t vote in their own polls. #zappoll + + + Image is the same since the post + Image has changed. The author might not have seen the change + From 79e3450a4695989ca9bd198d59abd84743d88d78 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 17:07:07 -0400 Subject: [PATCH 5/9] v0.34.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index db60144bb..a9d1ca074 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 121 - versionName "0.33.2" + versionCode 122 + versionName "0.34.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From e7909beaabbef318eda0c7cc25d8eec3b1ec840f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:00 -0400 Subject: [PATCH 6/9] Remove spaces after bench and image previews --- .../amethyst/ui/components/RichTextViewer.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 5a4fd7e74..c2ed2dfb0 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 @@ -154,7 +154,7 @@ fun RichTextViewer( // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> FlowRow() { - val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ') + val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ') s.forEach { word: String -> if (canPreview) { // Explicit URL @@ -385,9 +385,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV isQuotedNote = true, navController = navController ) - Text( - "${it.second} " - ) + if (!it.second.isNullOrEmpty()) { + Text( + "${it.second} " + ) + } } ?: nip19Route?.let { ClickableRoute(it, navController) } ?: Text(text = "$word ") From 9aa5993748da1278bd112489460757f62ea40870 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:15 -0400 Subject: [PATCH 7/9] Removes unnecessary logs --- .../amethyst/ui/navigation/AppBottomBar.kt | 94 +++++++++---------- 1 file changed, 42 insertions(+), 52 deletions(-) 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 dd08efd7e..db0ab3644 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 @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.ui.navigation import android.graphics.Rect -import android.util.Log import android.view.ViewTreeObserver import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -44,7 +43,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue val bottomNavigationItems = listOf( Route.Home, @@ -141,72 +139,64 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView @OptIn(ExperimentalTime::class) @Composable private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { - println("Notifiable Icon") + Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { + Icon( + painter = painterResource(id = route.icon), + contentDescription = null, + modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), + tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified + ) - val (value, elapsed) = measureTimedValue { - Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { - Icon( - painter = painterResource(id = route.icon), - contentDescription = null, - modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), - tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified - ) + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - println("Notifiable Icon") + // Notification + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val notifState = NotificationCache.live.observeAsState() + val notif = notifState.value ?: return - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return + var hasNewItems by remember { mutableStateOf(false) } - val notifState = NotificationCache.live.observeAsState() - val notif = notifState.value ?: return - - var hasNewItems by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = notif) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) - } + LaunchedEffect(key1 = notif) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) } + } - LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, db) - } + LaunchedEffect(key1 = db) { + withContext(Dispatchers.IO) { + hasNewItems = route.hasNewItems(account, notif.cache, db) } + } - if (hasNewItems) { + if (hasNewItems) { + Box( + Modifier + .width(10.dp) + .height(10.dp) + .align(Alignment.TopEnd) + ) { Box( - Modifier + modifier = Modifier .width(10.dp) .height(10.dp) - .align(Alignment.TopEnd) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.primary), + contentAlignment = Alignment.TopEnd ) { - Box( + Text( + "", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = 12.sp, modifier = Modifier - .width(10.dp) - .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.primary), - contentAlignment = Alignment.TopEnd - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, - modifier = Modifier - .wrapContentHeight() - .align(Alignment.TopEnd) - ) - } + .wrapContentHeight() + .align(Alignment.TopEnd) + ) } } } } - - Log.d("Notification time", "$elapsed") } From ed07248f7d1d8ca06c8804dcd3fd2624eb92a9c3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:20:26 -0400 Subject: [PATCH 8/9] Correctly calculates the total of Zaps in a poll --- .../vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 4ce21c372..b24d78fbd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -110,7 +110,13 @@ class PollNoteViewModel { fun totalZapped(): BigDecimal { return pollNote?.zaps?.values?.sumOf { - (it?.event as? LnZapEvent)?.amount ?: BigDecimal(0) + val zapEvent = (it?.event as? LnZapEvent) + + if (zapEvent?.zappedPollOption() != null) { + zapEvent.amount ?: BigDecimal(0) + } else { + BigDecimal(0) + } } ?: BigDecimal(0) } } From 830ade9ad67ac17fd62909c3677c100840321d39 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Apr 2023 18:22:11 -0400 Subject: [PATCH 9/9] v0.34.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a9d1ca074..5a206becf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 122 - versionName "0.34.0" + versionCode 123 + versionName "0.34.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {