diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e6e2910b..e7ef182e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Cache gradle uses: actions/cache@v1 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 853a6ad2b..e788e06bf 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -12,10 +12,10 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Cache gradle uses: actions/cache@v1 diff --git a/README.md b/README.md index 27b39d5a2..7d2eb1ce5 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro ## Setup Make sure to have the following pre-requisites installed: -1. Java 11 +1. Java 17 2. Android Studio 3. Android 8.0+ Phone or Emulation setup diff --git a/app/build.gradle b/app/build.gradle index fd5da4e0d..5a206becf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 118 - versionName "0.32.3" + versionCode 123 + versionName "0.34.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -57,12 +57,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '11' + jvmTarget = '17' } buildFeatures { @@ -78,10 +78,14 @@ android { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } + lint { + disable 'MissingTranslation' + } + } dependencies { - implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.activity:activity-compose:1.7.0-beta01' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" @@ -142,15 +146,14 @@ 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: implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" - // tabs for user profiles - implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager - implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version" - // Parses URLs from Text: implementation "io.github.url-detector:url-detector:0.1.23" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 518e6a539..3735c465f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt index 4d01c8301..bd785161b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt @@ -18,7 +18,7 @@ object VideoCache { lateinit var cacheDataSourceFactory: CacheDataSource.Factory - fun get(context: Context): CacheDataSource.Factory { + fun init(context: Context) { if (!this::simpleCache.isInitialized) { exoDatabaseProvider = StandaloneDatabaseProvider(context) @@ -35,7 +35,9 @@ object VideoCache { ) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) } + } + fun get(): CacheDataSource.Factory { return cacheDataSourceFactory } } 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 178e5f264..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 @@ -54,8 +55,14 @@ class Account( val liveLanguages: AccountLiveData = AccountLiveData(this) val saveable: AccountLiveData = AccountLiveData(this) + var userProfileCache: User? = null + fun userProfile(): User { - return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) + return userProfileCache ?: run { + val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) + userProfileCache = myUser + myUser + } } fun followingChannels(): List { @@ -351,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 @@ -722,11 +748,11 @@ class Account( fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers fun followingKeySet(): Set { - return userProfile().cachedFollowingKeySet() ?: emptySet() + return userProfile().cachedFollowingKeySet() } fun followingTagSet(): Set { - return userProfile().cachedFollowingTagSet() ?: emptySet() + return userProfile().cachedFollowingTagSet() } fun isAcceptable(user: User): Boolean { 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 cef63d463..11316a775 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay -import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.components.BundledInsert import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* import nostr.postr.toNpub @@ -189,7 +189,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: LongTextNoteEvent, relay: Relay?) { @@ -218,7 +218,7 @@ object LocalCache { author.addNote(note) - refreshObservers() + refreshObservers(note) } } @@ -255,7 +255,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: BadgeDefinitionEvent) { @@ -268,7 +268,7 @@ object LocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } @@ -286,8 +286,6 @@ object LocalCache { note.loadEvent(event, author, replyTo) author.updateAcceptedBadges(note) - - refreshObservers() } } @@ -309,7 +307,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } @Suppress("UNUSED_PARAMETER") @@ -353,7 +351,7 @@ object LocalCache { recipient.addMessage(author, note) } - refreshObservers() + refreshObservers(note) } fun consume(event: DeletionEvent) { @@ -401,7 +399,7 @@ object LocalCache { } if (deletedAtLeastOne) { - live.invalidateData() + // refreshObservers() } } @@ -427,7 +425,7 @@ object LocalCache { it.addBoost(note) } - refreshObservers() + refreshObservers(note) } fun consume(event: ReactionEvent) { @@ -465,6 +463,8 @@ object LocalCache { it.addReport(note) } } + + refreshObservers(note) } fun consume(event: ReportEvent, relay: Relay?) { @@ -495,6 +495,8 @@ object LocalCache { repliesTo.forEach { it.addReport(note) } + + refreshObservers(note) } fun consume(event: ChannelCreateEvent) { @@ -511,7 +513,7 @@ object LocalCache { oldChannel.addNote(note) note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } @@ -531,7 +533,7 @@ object LocalCache { oldChannel.addNote(note) note.loadEvent(event, author, emptyList()) - refreshObservers() + refreshObservers(note) } } else { // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") @@ -578,7 +580,7 @@ object LocalCache { it.addReply(note) } - refreshObservers() + refreshObservers(note) } @Suppress("UNUSED_PARAMETER") @@ -595,7 +597,7 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) } + val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) } val author = getOrCreateUser(event.pubKey) val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } @@ -620,6 +622,8 @@ object LocalCache { mentions.forEach { it.addZap(zapRequest, note) } + + refreshObservers(note) } fun consume(event: LnZapRequestEvent) { @@ -643,6 +647,21 @@ object LocalCache { mentions.forEach { it.addZap(note, null) } + + 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 { @@ -694,11 +713,6 @@ object LocalCache { notes.remove(it.idHex) // Doesn't need to clean up the replies and mentions.. Too small to matter. - // reverts the add - val mentions = - it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) } - ?.mapNotNull { checkGetOrCreateUser(it) } - // Counts the replies it.replyTo?.forEach { _ -> it.removeReply(it) @@ -749,30 +763,23 @@ object LocalCache { } // Observers line up here. - val live: LocalCacheLiveData = LocalCacheLiveData(this) + val live: LocalCacheLiveData = LocalCacheLiveData() - private fun refreshObservers() { - live.invalidateData() + private fun refreshObservers(newNote: Note) { + live.invalidateData(newNote) } } -class LocalCacheLiveData(val cache: LocalCache) : - LiveData(LocalCacheState(cache)) { +class LocalCacheLiveData : LiveData>(setOf()) { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { - if (hasActiveObservers()) { - refresh() + private val bundler = BundledInsert(300, Dispatchers.Main) + + fun invalidateData(newNote: Note) { + bundler.invalidateList(newNote) { bundledNewNotes -> + if (hasActiveObservers()) { + postValue(bundledNewNotes) + } } } - - fun invalidateData() { - bundler.invalidate() - } - - private fun refresh() { - postValue(LocalCacheState(cache)) - } } - -class LocalCacheState(val cache: LocalCache) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index f12698a33..7bf4537f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -53,13 +53,14 @@ open class Note(val idHex: String) { open fun idNote() = id().toNote() open fun idDisplayNote() = idNote().toShortenHex() - fun channel(): Channel? { - val channelHex = - (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() - ?: (event as? ChannelCreateEvent)?.id + fun channelHex(): HexKey? { + return (event as? ChannelMessageEvent)?.channel() + ?: (event as? ChannelMetadataEvent)?.channel() + ?: (event as? ChannelCreateEvent)?.id + } - return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } + fun channel(): Channel? { + return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) } } open fun address(): ATag? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 476f4ef7d..9696a5795 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -295,7 +295,7 @@ class User(val pubkeyHex: String) { fun hasSentMessagesTo(user: User?): Boolean { val messagesToUser = privateChatrooms[user] ?: return false - return messagesToUser.roomMessages.any { this == it.author } + return messagesToUser.roomMessages.any { this === it.author } } fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { 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 755fbf607..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,8 +75,9 @@ abstract class NostrDataSource(val debugName: String) { is ContactListEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event) + is FileHeaderEvent -> LocalCache.consume(event) is LnZapEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } + event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) } is LnZapRequestEvent -> LocalCache.consume(event) @@ -154,7 +155,7 @@ abstract class NostrDataSource(val debugName: String) { // Refreshes observers in batches. private val bundler = BundledUpdate(250, Dispatchers.IO) { - println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index d3fe1e4ce..81df891f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -17,7 +17,14 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { return TypedFilter( types = FeedType.values().toSet(), filter = JsonFilter( - tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())), + tags = mapOf( + "t" to listOf( + hashToLoad, + hashToLoad.lowercase(), + hashToLoad.uppercase(), + hashToLoad.capitalize() + ) + ), kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), limit = 200 ) 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 29f6cf38c..9a6a6568a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter -import java.util.Date object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { private var eventsToWatch = setOf() @@ -20,8 +19,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return null } - val now = Date().time / 1000 - return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( @@ -49,8 +46,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return null } - val now = Date().time / 1000 - return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( @@ -116,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/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index f1502a24a..7919354c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { ) } - val loadEventsChannel = requestNewChannel() { eoseTime, relay -> + val loadEventsChannel = requestNewChannel() { _, _ -> // Many relays operate with limits in the amount of filters. // As information comes, the filters will be rotated to get more data. invalidateFilters() 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/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index 1812fb803..5f08c6a6e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -4,7 +4,6 @@ import android.util.Log import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.relays.Client -import java.math.BigDecimal class LnZapEvent( id: HexKey, @@ -14,25 +13,37 @@ class LnZapEvent( content: String, sig: HexKey ) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { + // This event is also kept in LocalCache (same object) + @Transient val zapRequest: LnZapRequestEvent? - override fun zappedPost() = tags - .filter { it.firstOrNull() == "e" } - .mapNotNull { it.getOrNull(1) } - - override fun zappedPollOption(): Int? = containedPost()?.tags - ?.filter { it.firstOrNull() == POLL_OPTION } - ?.getOrNull(0)?.getOrNull(1)?.toInt() - - override fun zappedAuthor() = tags - .filter { it.firstOrNull() == "p" } - .mapNotNull { it.getOrNull(1) } - - override fun zappedRequestAuthor(): String? = containedPost()?.pubKey() - - override fun amount(): BigDecimal? { - return amount + private fun containedPost(): LnZapRequestEvent? = try { + description()?.ifBlank { null }?.let { + fromJson(it, Client.lenient) + } as? LnZapRequestEvent + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) + null } + init { + zapRequest = containedPost() + } + + override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + + override fun zappedPollOption(): Int? = try { + zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt() + } catch (e: Exception) { + Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) + null + } + + override fun zappedRequestAuthor(): String? = zapRequest?.pubKey() + + override fun amount() = amount + // Keeps this as a field because it's a heavier function used everywhere. val amount by lazy { try { @@ -42,29 +53,14 @@ class LnZapEvent( null } } + override fun message(): String { - return message - } - val message = content - - override fun containedPost(): Event? = try { - description()?.ifBlank { null }?.let { - fromJson(it, Client.lenient) - } - } catch (e: Exception) { - Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) - null + return content } - private fun lnInvoice(): String? = tags - .filter { it.firstOrNull() == "bolt11" } - .mapNotNull { it.getOrNull(1) } - .firstOrNull() + private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) - private fun description(): String? = tags - .filter { it.firstOrNull() == "description" } - .mapNotNull { it.getOrNull(1) } - .firstOrNull() + private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) companion object { const val kind = 9735 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt index b69e02781..ada0d609d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt @@ -16,7 +16,5 @@ interface LnZapEventInterface : EventInterface { fun amount(): BigDecimal? - fun containedPost(): Event? - fun message(): String } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 34fddc02a..eba7a18a9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -13,8 +13,10 @@ class LnZapRequestEvent( content: String, sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + + fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + + fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } companion object { const val kind = 9734 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 a6bcc89b8..c91cd4c7a 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 @@ -20,24 +20,17 @@ class PollNoteEvent( content: String, sig: HexKey ) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { - fun pollOptions(): Map { - val map = mutableMapOf() - tags.filter { it.first() == POLL_OPTION } - .forEach { map[it[1].toInt()] = it[2] } - return map - } + fun pollOptions() = + tags.filter { it.size > 2 && it[0] == POLL_OPTION } + .associate { it[1].toInt() to it[2] } fun getTagInt(property: String): Int? { - val tagList = tags.filter { - it.firstOrNull() == property - } - val tag = tagList.getOrNull(0) - val s = tag?.getOrNull(1) + val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1) - return if (s.isNullOrBlank() || s == "null") { + return if (number.isNullOrBlank() || number == "null") { null } else { - s.toInt() + number.toInt() } } 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 89e1d98e6..c820d0ca2 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 @@ -21,7 +21,7 @@ class PrivateDmEvent( * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * for initial messages. */ - fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one /** * To be fully compatible with nip-04, we read e-tags that are in violation to nip-18. @@ -29,7 +29,7 @@ class PrivateDmEvent( * Nip-18 messages should refer to other events by inline references in the content like * `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506). */ - fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? { return try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index f89bb2977..13188520c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -14,8 +14,8 @@ class ReactionEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } companion object { const val kind = 7 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt index 42f33c3c5..d3bd9dd97 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt @@ -30,7 +30,7 @@ class ReportEvent( } fun reportedPost() = tags - .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } + .filter { it.size > 1 && it[0] == "e" } .map { ReportedKey( it[1], @@ -39,7 +39,7 @@ class ReportEvent( } fun reportedAuthor() = tags - .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } + .filter { it.size > 1 && it[0] == "p" } .map { ReportedKey( it[1], diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 49823b3e4..e60c431fc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager +import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.navigation.Route @@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() { null } + // Initializes video cache. + VideoCache.init(this.applicationContext) + Coil.setImageLoader { ImageLoader.Builder(this).components { if (SDK_INT >= 28) { 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/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 516ac6e52..b0d6428f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19 class NewMessageTagger(var channel: Channel?, var mentions: List?, var replyTos: List?, var message: String) { - open fun addUserToMentions(user: User) { + fun addUserToMentions(user: User) { mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user) } - open fun addNoteToReplyTos(note: Note) { + fun addNoteToReplyTos(note: Note) { note.author?.let { addUserToMentions(it) } replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note) } - open fun tagIndex(user: User): Int { + fun tagIndex(user: User): Int { // Postr Events assembles replies before mentions in the tag order return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0) } - open fun tagIndex(note: Note): Int { + fun tagIndex(note: Note): Int { // Postr Events assembles replies before mentions in the tag order return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index 876134cff..62c607638 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R @Composable fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) { - val colorInValid = TextFieldDefaults.outlinedTextFieldColors( - focusedBorderColor = MaterialTheme.colors.error, - unfocusedBorderColor = Color.Red - ) - val colorValid = TextFieldDefaults.outlinedTextFieldColors( - focusedBorderColor = MaterialTheme.colors.primary, - unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - Row { val deleteIcon: @Composable (() -> Unit) = { IconButton( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt index 12977d096..a238e1285 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt @@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n Text(stringResource(R.string.poll_heading_required)) // NewPollRecipientsField(pollViewModel, account) NewPollPrimaryDescription(pollViewModel) - pollViewModel.pollOptions.values.forEachIndexed { index, element -> + pollViewModel.pollOptions.values.forEachIndexed { index, _ -> NewPollOption(pollViewModel, index) } Button( @@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n itemsIndexed( userSuggestions, key = { _, item -> item.pubkeyHex } - ) { index, item -> + ) { _, item -> UserLine(item, account) { pollViewModel.autocompleteWithUser(item) } 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 213573b22..35210be77 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 @@ -167,7 +167,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n ) if (postViewModel.wantsPoll) { - postViewModel.pollOptions.values.forEachIndexed { index, element -> + postViewModel.pollOptions.values.forEachIndexed { index, _ -> NewPollOption(postViewModel, index) } @@ -189,7 +189,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n val user = postViewModel.account?.userProfile() val lud16 = user?.info?.lnAddress() - if (lud16 != null && user != null && postViewModel.wantsInvoice) { + if (lud16 != null && postViewModel.wantsInvoice) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { InvoiceRequest( lud16, 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 1a98c4e97..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 @@ -72,7 +73,7 @@ open class NewPostViewModel : ViewModel() { this.mentions = currentMentions.plus(replyUser) } } - } ?: { + } ?: run { replyTos = null mentions = null } @@ -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/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index d2172e560..ea6dff88f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference /** * This class is designed to have a waiting time between two calls of invalidate @@ -44,3 +45,37 @@ class BundledUpdate( } } } + +/** + * This class is designed to have a waiting time between two calls of invalidate + */ +class BundledInsert( + val delay: Long, + val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { + private var onlyOneInBlock = AtomicBoolean() + private var atomicSet = AtomicReference>(setOf()) + + fun invalidateList(newObject: T, onUpdate: (Set) -> Unit) { + atomicSet.updateAndGet() { + it + newObject + } + + if (onlyOneInBlock.getAndSet(true)) { + return + } + + val scope = CoroutineScope(Job() + dispatcher) + scope.launch { + try { + onUpdate(atomicSet.getAndSet(emptySet())) + delay(delay) + onUpdate(atomicSet.getAndSet(emptySet())) + } finally { + withContext(NonCancellable) { + onlyOneInBlock.set(false) + } + } + } + } +} 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 474a1fea7..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 @@ -14,6 +14,7 @@ import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -32,6 +33,8 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material.MaterialRichText import com.halilibo.richtext.ui.resolveDefaults +import com.linkedin.urls.detection.UrlDetector +import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -123,48 +126,43 @@ fun RichTextViewer( ) ) + val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content) + MaterialRichText( style = myMarkDownStyle ) { Markdown( - content = content, + content = markdownWithSpecialContent, markdownParseOptions = MarkdownParseOptions.Default ) } } else { - val imagesForPager = mutableListOf() - - content.split('\n').forEach { paragraph -> - paragraph.split(' ').forEach { word: String -> - // sequence of images will render in a slideview - if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - imagesForPager.add(word) - } - if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - imagesForPager.add(word) - } - } + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } + val imagesForPager = urlSet.mapNotNull { fullUrl -> + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + 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 -> 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 - if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImageView(word, imagesForPager) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImageView(word, imagesForPager) - } else { - UrlPreview(word, "$word ") - } + val img = imagesForPager[word] + if (img != null) { + ZoomableContentView(img, imageList) + } else if (urlSet.contains(word)) { + UrlPreview(word, "$word ") } else if (word.startsWith("lnbc", true)) { MayBeInvoicePreview(word) } else if (word.startsWith("lnurl", true)) { @@ -192,7 +190,7 @@ fun RichTextViewer( navController ) } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, accountViewModel, navController) + HashTag(word, navController) } else { Text( text = "$word ", @@ -205,8 +203,15 @@ fun RichTextViewer( val url = matcher.group(1) // url val additionalChars = matcher.group(4) ?: "" // additional chars - ClickableUrl(url, "https://$url") - Text("$additionalChars ") + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } else { Text( text = "$word ", @@ -214,7 +219,7 @@ fun RichTextViewer( ) } } else { - if (isValidURL(word)) { + if (urlSet.contains(word)) { ClickableUrl("$word ", word) } else if (word.startsWith("lnurl", true)) { val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) @@ -249,7 +254,7 @@ fun RichTextViewer( navController ) } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, accountViewModel, navController) + HashTag(word, navController) } else { Text( text = "$word ", @@ -262,8 +267,15 @@ fun RichTextViewer( val url = matcher.group(1) // url val additionalChars = matcher.group(4) ?: "" // additional chars - ClickableUrl(url, "https://$url") - Text("$additionalChars ") + if (url != null) { + ClickableUrl(url, "https://$url") + Text("$additionalChars ") + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } else { Text( text = "$word ", @@ -278,6 +290,53 @@ fun RichTextViewer( } } +@Composable +private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? { + if (parsedNip19.type == Nip19.Type.USER) { + val userHex = parsedNip19.hex + val userBase = LocalCache.getOrCreateUser(userHex) + + val userState by userBase.live().metadata.observeAsState() + val displayName = userState?.user?.bestDisplayName() + if (displayName !== null) { + return displayName + } + } + return null +} + +@Composable +private fun returnMarkdownWithSpecialContent(content: String): String { + var returnContent = "" + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + if (isValidURL(word)) { + returnContent += "[$word]($word) " + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + returnContent += "[$word](mailto:$word) " + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + returnContent += "[$word](tel:$word) " + } else if (isBechLink(word)) { + val parsedNip19 = Nip19.uriToRoute(word) + returnContent += if (parsedNip19 !== null) { + val displayName = getDisplayNameFromUserNip19(parsedNip19) + if (displayName != null) { + "[@$displayName](nostr://$word) " + } else { + "$word " + } + } else { + "$word " + } + } else { + returnContent += "$word " + } + } + returnContent += "\n" + } + return returnContent +} + private fun isArabic(text: String): Boolean { return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } } @@ -326,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 ") @@ -340,7 +401,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV } @Composable -fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { +fun HashTag(word: String, navController: NavController) { var tagSuffixPair by remember { mutableStateOf?>(null) } LaunchedEffect(key1 = word) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt index 556c3a134..dedb0cdd4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -11,6 +11,7 @@ import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.request.ImageRequest import coil.request.Options +import coil.size.Size import okio.Buffer import java.security.MessageDigest @@ -61,6 +62,7 @@ private fun svgString(msg: String): String { class HashImageFetcher( private val context: Context, + private val size: Size, private val data: Uri ) : Fetcher { @@ -79,16 +81,17 @@ class HashImageFetcher( object Factory : Fetcher.Factory { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher { - return HashImageFetcher(options.context, data) + return HashImageFetcher(options.context, options.size, data) } } } object Robohash { - fun imageRequest(context: Context, message: String): ImageRequest { + fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest { return ImageRequest .Builder(context) .data("robohash:$message") .fetcherFactory(HashImageFetcher.Factory) + .size(robotSize) .crossfade(100) .build() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 73b500ccf..c50d08163 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter +import coil.size.Size @Composable fun RobohashAsyncImage( robot: String, + robotSize: Dp, modifier: Modifier = Modifier, contentDescription: String? = null, transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, @@ -26,24 +30,33 @@ fun RobohashAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - AsyncImage( - model = Robohash.imageRequest(LocalContext.current, robot), - contentDescription = contentDescription, - modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) + with(LocalDensity.current) { + AsyncImage( + model = Robohash.imageRequest( + LocalContext.current, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ), + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } } +var imageErrors = setOf() + @Composable fun RobohashFallbackAsyncImage( robot: String, - model: String?, + robotSize: Dp, + model: String, contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, @@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - val context = LocalContext.current - val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot)) + if (imageErrors.contains(model)) { + RobohashAsyncImage( + robot = robot, + robotSize = robotSize, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } else { + val context = LocalContext.current + val painter = with(LocalDensity.current) { + rememberAsyncImagePainter( + model = Robohash.imageRequest( + context, + robot, + Size(robotSize.roundToPx(), robotSize.roundToPx()) + ) + ) + } - AsyncImage( - model = model, - contentDescription = contentDescription, - modifier = modifier, - placeholder = painter, - fallback = painter, - error = painter, - alignment = alignment, - contentScale = contentScale, - alpha = alpha, - colorFilter = colorFilter, - filterQuality = filterQuality - ) + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = painter, + fallback = painter, + error = painter, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality, + onError = { + imageErrors = imageErrors + model + } + ) + } } @Composable @@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy( colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality ) { - if (model.url == null) { + val proxy = model.proxyUrl() + if (proxy == null) { RobohashAsyncImage( robot = robot, + robotSize = model.size, contentDescription = contentDescription, modifier = modifier, alignment = alignment, @@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy( } else { RobohashFallbackAsyncImage( robot = robot, - model = model.proxyUrl(), + robotSize = model.size, + model = proxy, contentDescription = contentDescription, modifier = modifier, alignment = alignment, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index 03636135b..52f2328a7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Box @@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.PagerState -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun SlidingCarousel( modifier: Modifier = Modifier, @@ -39,7 +39,7 @@ fun SlidingCarousel( Box( modifier = modifier.fillMaxWidth() ) { - HorizontalPager(count = itemsCount, state = pagerState) { page -> + HorizontalPager(pageCount = itemsCount, state = pagerState) { page -> itemContent(page) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index e8453aff5..561d86ab3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -41,24 +41,24 @@ fun UrlPreviewCard( ) ) { Column { - val url = URL(previewInfo.url) + val validatedUrl = URL(previewInfo.url) // correctly treating relative images val imageUrl = if (previewInfo.image.startsWith("/")) { - URL(url, previewInfo.image).toString() + URL(validatedUrl, previewInfo.image).toString() } else { previewInfo.image } AsyncImage( model = imageUrl, - contentDescription = stringResource(R.string.preview_card_image_for, url), + contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth() ) Text( - text = url.host, + text = validatedUrl.host, style = MaterialTheme.typography.caption, modifier = Modifier .fillMaxWidth() 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 ea18cd3b5..ab67d171d 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 @@ -6,9 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem @@ -21,44 +25,54 @@ import com.vitorpamplona.amethyst.VideoCache @Composable fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) - val exoPlayer = remember { + val exoPlayer = remember(videoUri) { ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri)) + ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri)) ) prepare() } } - val playerView = remember { - StyledPlayerView(context).apply { - player = exoPlayer - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { - innerOnDialog(it) + DisposableEffect( + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { + StyledPlayerView(context).apply { + player = exoPlayer + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + onDialog?.let { innerOnDialog -> + setFullscreenButtonClickListener { + exoPlayer.pause() + innerOnDialog(it) + } + } } } + ) + ) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + exoPlayer.pause() + } + else -> {} + } } - } + val lifecycle = lifecycleOwner.value.lifecycle + lifecycle.addObserver(observer) - DisposableEffect(exoPlayer) { onDispose { exoPlayer.release() + lifecycle.removeObserver(observer) } } - - AndroidView( - modifier = Modifier.fillMaxWidth(), - factory = { - playerView - } - ) } 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 e547da6bd..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ /dev/null @@ -1,189 +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.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.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.PagerState -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(ExperimentalPagerApi::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/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 6e19217dd..aa2921dce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -object ChannelFeedFilter : FeedFilter() { +object ChannelFeedFilter : AdditiveFeedFilter() { lateinit var account: Account lateinit var channel: Channel @@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter() { .sortedBy { it.createdAt() } .reversed() } + + override fun applyFilter(collection: Set): Set { + return collection + .filter { it.idHex in channel.notes.keys && account.isAcceptable(it) } + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 9d77e1ac4..f96c68681 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -object ChatroomFeedFilter : FeedFilter() { +object ChatroomFeedFilter : AdditiveFeedFilter() { var account: Account? = null var withUser: User? = null @@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter() { .sortedBy { it.createdAt() } .reversed() } + + override fun applyFilter(collection: Set): Set { + val myAccount = account + val myUser = withUser + + if (myAccount == null || myUser == null) return emptySet() + + val messages = myAccount + .userProfile() + .privateChatrooms[myUser] ?: return emptySet() + + return collection + .filter { it in messages.roomMessages && account?.isAcceptable(it) == true } + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index ddafd9e4a..396933e94 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter() { override fun feed(): List { val me = account.userProfile() - val privateChatrooms = account.userProfile().privateChatrooms + val privateChatrooms = me.privateChatrooms val messagingWith = privateChatrooms.keys.filter { me.hasSentMessagesTo(it) && account.isAcceptable(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 6547f00fc..505a959ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -4,7 +4,7 @@ import android.util.Log import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue -abstract class FeedFilter() { +abstract class FeedFilter { @OptIn(ExperimentalTime::class) fun loadTop(): List { val (feed, elapsed) = measureTimedValue { @@ -17,3 +17,24 @@ abstract class FeedFilter() { abstract fun feed(): List } + +abstract class AdditiveFeedFilter : FeedFilter() { + abstract fun applyFilter(collection: Set): Set + abstract fun sort(collection: Set): List + + @OptIn(ExperimentalTime::class) + fun updateListWith(oldList: List, newItems: Set): List { + val (feed, elapsed) = measureTimedValue { + val newItemsToBeAdded = applyFilter(newItems) + if (newItemsToBeAdded.isNotEmpty()) { + val newList = oldList.toSet() + newItemsToBeAdded + sort(newList).take(1000) + } else { + oldList + } + } + + Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects") + return feed + } +} 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 e902c2f98..f17d5ade5 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 @@ -5,21 +5,32 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.* -object GlobalFeedFilter : FeedFilter() { +object GlobalFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { - val followChannels = account.followingChannels() + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) + + return sort(notes + longFormNotes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val followChannels = account.followingChannels val followUsers = account.followingKeySet() val now = System.currentTimeMillis() / 1000 - val notes = LocalCache.notes.values + return collection .asSequence() .filter { it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty() } .filter { - val channel = it.channel() + val channel = it.channelHex() // does not show events already in the public chat list (channel == null || channel !in followChannels) && // does not show people the user already follows @@ -30,29 +41,10 @@ object GlobalFeedFilter : FeedFilter() { // 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 } - .toList() + .toSet() + } - val longFormNotes = LocalCache.addressables.values - .asSequence() - .filter { - it.event is LongTextNoteEvent && it.replyTo.isNullOrEmpty() - } - .filter { - val channel = it.channel() - // does not show events already in the public chat list - (channel == null || channel !in followChannels) && - // does not show people the user already follows - (it.author?.pubkeyHex !in followUsers) - } - .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 - } - .toList() - - return (notes + longFormNotes) - .sortedBy { it.createdAt() } - .reversed() + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } 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 844505ff3..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 @@ -8,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HashtagFeedFilter : FeedFilter() { +object HashtagFeedFilter : AdditiveFeedFilter() { lateinit var account: Account var tag: String? = null - override fun feed(): List { - val myTag = tag ?: return emptyList() + fun loadHashtag(account: Account, tag: String?) { + this.account = account + this.tag = tag + } - return LocalCache.notes.values + override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + val myTag = tag ?: return emptySet() + + return collection .asSequence() .filter { ( @@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter() { it.event?.isTaggedHash(myTag) == true } .filter { account.isAcceptable(it) } - .sortedBy { it.createdAt() } - .toList() - .reversed() + .toSet() } - fun loadHashtag(account: Account, tag: String?) { - this.account = account - this.tag = tag + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 4cbb5f5dc..6026e2669 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -6,15 +6,24 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HomeConversationsFeedFilter : FeedFilter() { +object HomeConversationsFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() - return LocalCache.notes.values + return collection + .asSequence() .filter { (it.event is TextNoteEvent || it.event is PollNoteEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && @@ -22,7 +31,10 @@ object HomeConversationsFeedFilter : FeedFilter() { it.author?.let { !account.isHidden(it) } ?: true && !it.isNewThread() } - .sortedBy { it.createdAt() } - .reversed() + .toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } 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 bff02cd2f..d31a45c68 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 @@ -8,34 +8,38 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -object HomeNewThreadFeedFilter : FeedFilter() { +object HomeNewThreadFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) + val longFormNotes = innerApplyFilter(LocalCache.addressables.values) + + return sort(notes + longFormNotes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { val user = account.userProfile() val followingKeySet = user.cachedFollowingKeySet() val followingTagSet = user.cachedFollowingTagSet() - val notes = LocalCache.notes.values + return collection + .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) && (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 && it.isNewThread() } + .toSet() + } - val longFormNotes = LocalCache.addressables.values - .filter { it -> - (it.event is LongTextNoteEvent) && - (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 && - it.isNewThread() - } - - return (notes + longFormNotes) - .sortedBy { it.createdAt() } - .reversed() + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index f1d53c073..ba746ebba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -6,14 +6,22 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.* -object NotificationFeedFilter : FeedFilter() { +object NotificationFeedFilter : AdditiveFeedFilter() { lateinit var account: Account override fun feed(): List { + return sort(innerApplyFilter(LocalCache.notes.values)) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { val loggedInUser = account.userProfile() val loggedInUserHex = loggedInUser.pubkeyHex - return LocalCache.notes.values.filter { + return collection.filter { it.event !is ChannelCreateEvent && it.event !is ChannelMetadataEvent && it.event !is LnZapRequestEvent && @@ -23,9 +31,11 @@ object NotificationFeedFilter : FeedFilter() { it.event?.isTaggedUser(loggedInUserHex) ?: false && (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && tagsAnEventByUser(it, loggedInUser) - } - .sortedBy { it.createdAt() } - .reversed() + }.toSet() + } + + override fun sort(collection: Set): List { + return collection.sortedBy { it.createdAt() }.reversed() } fun tagsAnEventByUser(note: Note, author: User): Boolean { 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 e275cf4f6..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 @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.time.ExperimentalTime val bottomNavigationItems = listOf( Route.Home, @@ -135,6 +136,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView } } +@OptIn(ExperimentalTime::class) @Composable private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { @@ -159,13 +161,13 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac LaunchedEffect(key1 = notif) { withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache) + hasNewItems = route.hasNewItems(account, notif.cache, emptySet()) } } LaunchedEffect(key1 = db) { withContext(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache) + hasNewItems = route.hasNewItems(account, notif.cache, db) } } 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 d7418272c..fc655954e 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 @@ -1,5 +1,7 @@ package com.vitorpamplona.amethyst.ui.navigation +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun AppNavigation( navController: NavHostController, 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 ddbe4d307..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 @@ -10,6 +10,8 @@ import androidx.navigation.navArgument 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 @@ -17,7 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter sealed class Route( val route: String, val icon: Int, - val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false }, + val hasNewItems: (Account, NotificationCache, Set) -> Boolean = { _, _, _ -> false }, val arguments: List = emptyList() ) { val base: String @@ -30,7 +32,7 @@ sealed class Route( navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }, navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null } ), - hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object Search : Route( @@ -43,13 +45,13 @@ sealed class Route( route = "Notification?scrollToTop={scrollToTop}", icon = R.drawable.ic_notifications, arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }), - hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object Message : Route( route = "Message", icon = R.drawable.ic_dm, - hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) } + hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) } ) object BlockedUsers : Route( @@ -108,36 +110,72 @@ fun currentRoute(navController: NavHostController): String? { return navBackStackEntry?.destination?.route } -private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean { - val lastTime = cache.load("HomeFollows") +open class LatestItem { + var newestItemPerAccount: Map = mapOf() - HomeNewThreadFeedFilter.account = account + fun updateNewestItem(newNotes: Set, account: Account, filter: AdditiveFeedFilter): Note? { + val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] - return ( - HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() - ?: 0 - ) > lastTime + 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] + } } -private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean { - val lastTime = cache.load("Notification") +object HomeLatestItem : LatestItem() { + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + val lastTime = cache.load("HomeFollows") + HomeNewThreadFeedFilter.account = account - NotificationFeedFilter.account = account + val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter) - return ( - NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() - ?: 0 - ) > lastTime + return (newestItem?.createdAt() ?: 0) > lastTime + } } -private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean { - ChatroomListKnownFeedFilter.account = account +object NotificationLatestItem : LatestItem() { + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + val lastTime = cache.load("Notification") + NotificationFeedFilter.account = account - val note = ChatroomListKnownFeedFilter.feed().firstOrNull { - it.createdAt() != null && it.channel() == null && it.author != account.userProfile() - } ?: return false + val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter) - val lastTime = cache.load("Room/${note.author?.pubkeyHex}") - - return (note.createdAt() ?: 0) > lastTime + return (newestItem?.createdAt() ?: 0) > lastTime + } +} + +object MessagesLatestItem { + fun hasNewItems( + account: Account, + cache: NotificationCache, + newNotes: Set + ): Boolean { + ChatroomListKnownFeedFilter.account = account + + val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull { + it.createdAt() != null && it.channel() == null && it.author != account.userProfile() + } ?: return false + + val lastTime = cache.load("Room/${note.author?.pubkeyHex}") + + return (note.createdAt() ?: 0) > lastTime + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index fee45ca0e..1294cf883 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -246,30 +246,32 @@ fun ChatroomMessageCompose( Row(verticalAlignment = Alignment.CenterVertically) { val event = note.event if (event is ChannelCreateEvent) { + val channelInfo = event.channelInfo() Text( text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.created)} " + ( - event.channelInfo().name + channelInfo.name ?: "" ) + " ${stringResource(R.string.with_description_of)} '" + ( - event.channelInfo().about + channelInfo.about ?: "" ) + "', ${stringResource(R.string.and_picture)} '" + ( - event.channelInfo().picture + channelInfo.picture ?: "" ) + "'" ) } else if (event is ChannelMetadataEvent) { + val channelInfo = event.channelInfo() Text( text = note.author?.toBestDisplayName() .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( - event.channelInfo().name + channelInfo.name ?: "" ) + "$', {stringResource(R.string.description_to)} '" + ( - event.channelInfo().about + channelInfo.about ?: "" ) + "', ${stringResource(R.string.and_picture_to)} '" + ( - event.channelInfo().picture + channelInfo.picture ?: "" ) + "'" ) @@ -371,6 +373,7 @@ private fun RelayBadges(baseNote: Note) { ) { RobohashFallbackAsyncImage( robot = "https://$url/favicon.ico", + robotSize = 15.dp, model = "https://$url/favicon.ico", contentDescription = stringResource(id = R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index bf0fa2cdb..8deb5bcb1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.screen.MessageSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers @@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal } else { var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = messageSetCard) { + LaunchedEffect(key1 = messageSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = messageSetCard.createdAt() > NotificationCache.load(routeForLastRead) @@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}") { - launchSingleTop = true - } - } else { + if (noteEvent is ChannelMessageEvent) { note.channel()?.let { navController.navigate("Channel/${it.idHex}") } + } else if (noteEvent is PrivateDmEvent) { + val replyAuthorBase = + (note.event as? PrivateDmEvent) + ?.recipientPubKey() + ?.let { LocalCache.getOrCreateUser(it) } + + var userToComposeOn = note.author!! + + if (replyAuthorBase != null) { + if (note.author == accountViewModel.userProfile()) { + userToComposeOn = replyAuthorBase + } + } + + navController.navigate("Room/${userToComposeOn.pubkeyHex}") + } else { + navController.navigate("Note/${note.idHex}") { + launchSingleTop = true + } } }, onLongClick = { popupExpanded = true } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index b001cce84..537649905 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -61,7 +61,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } else { var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = multiSetCard) { + LaunchedEffect(key1 = multiSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) @@ -244,7 +244,7 @@ fun FastNoteAuthorPicture( val userState by author.live().metadata.observeAsState() val user = userState?.user ?: return - val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount + val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount UserPicture( userHex = user.pubkeyHex, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 53c570214..cebfe1fdd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie user.nip05()?.let { nip05 -> if (nip05.split("@").size == 2) { - val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) Column(modifier = columnModifier) { Row(verticalAlignment = Alignment.CenterVertically) { if (nip05.split("@")[0] != "_") { @@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie ) } + val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex) if (nip05Verified == null) { Icon( tint = Color.Yellow, 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 be5075c25..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 @@ -5,8 +5,6 @@ import android.graphics.Bitmap import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -61,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.math.BigDecimal -import kotlin.math.ceil import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -96,7 +93,7 @@ fun NoteCompose( ) } - Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") + Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}") } @OptIn(ExperimentalFoundationApi::class) @@ -170,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) } @@ -299,7 +298,7 @@ fun NoteComposeInner( } if (noteEvent is RepostEvent) { - note.replyTo?.lastOrNull()?.let { + baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it) } } else { @@ -496,13 +495,13 @@ fun NoteComposeInner( noteEvent.recipientPubKey() != loggedIn.pubkeyHex && note.author !== loggedIn ) { - val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } + val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } TranslatableRichTextViewer( stringResource( id = R.string.private_conversation_notification, "@${note.author?.pubkeyNpub()}", - "@${recepient?.pubkeyNpub()}" + "@${recipient?.pubkeyNpub()}" ), canPreview = !makeItShort, Modifier.fillMaxWidth(), @@ -582,7 +581,9 @@ fun DisplayFollowingHashtagsInPost( var firstTag by remember { mutableStateOf(null) } LaunchedEffect(key1 = noteEvent) { - firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet()) + withContext(Dispatchers.IO) { + firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet()) + } } Column() { @@ -777,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( @@ -869,67 +894,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() - val noteRelays = noteRelaysState?.note?.relays ?: emptySet() + val noteRelays = noteRelaysState?.note ?: return var expanded by remember { mutableStateOf(false) } + var showShowMore by remember { mutableStateOf(false) } + var lazyRelayList by remember { mutableStateOf(emptyList()) } - val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList() - val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp + LaunchedEffect(key1 = noteRelaysState, key2 = expanded) { + withContext(Dispatchers.IO) { + val relayList = noteRelays.relays.map { + it.removePrefix("wss://").removePrefix("ws://") + } - val uri = LocalUriHandler.current + val relaysToDisplay = if (expanded) relayList else relayList.take(3) + val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size - Spacer(Modifier.height(10.dp)) + if (shouldListChange) { + lazyRelayList = relaysToDisplay + } - LazyVerticalGrid( - columns = GridCells.Fixed(3), - contentPadding = PaddingValues(start = 4.dp, end = 4.dp), - modifier = Modifier.height(height), - userScrollEnabled = false - ) { - items(relaysToDisplay.size) { - val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://") - - Box( - Modifier - .padding(1.dp) - .size(15.dp) - ) { - RobohashFallbackAsyncImage( - robot = "https://$url/favicon.ico", - model = "https://$url/favicon.ico", - contentDescription = stringResource(R.string.relay_icon), - colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), - modifier = Modifier - .width(13.dp) - .height(13.dp) - .clip(shape = CircleShape) - // .border(1.dp, Color.Red) - .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://$url") }) - ) + val nextShowMore = relayList.size > 3 && !expanded + if (nextShowMore != showShowMore) { + // only triggers recomposition when actually different + showShowMore = nextShowMore } } } - if (noteRelays.size > 3 && !expanded) { - Row( - Modifier - .fillMaxWidth() - .height(25.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.Top + Spacer(Modifier.height(10.dp)) + + VerticalRelayPanelWithFlow(lazyRelayList) + + if (showShowMore) { + ShowMoreRelaysButton { + expanded = true + } + } +} + +@Composable +@Stable +private fun VerticalRelayPanelWithFlow( + relays: List +) { + // FlowRow Seems to be a lot faster than LazyVerticalGrid + FlowRow() { + relays.forEach { url -> + RelayIconCompose(url) + } + } +} + +@Composable +@Stable +private fun RelayIconCompose(url: String) { + val uri = LocalUriHandler.current + + Box( + Modifier + .padding(1.dp) + .size(15.dp) + ) { + RobohashFallbackAsyncImage( + robot = "https://$url/favicon.ico", + robotSize = 15.dp, + model = "https://$url/favicon.ico", + contentDescription = stringResource(R.string.relay_icon), + colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), + modifier = Modifier + .width(13.dp) + .height(13.dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colors.background) + .clickable(onClick = { uri.openUri("https://$url") }) + ) + } +} + +@Composable +private fun ShowMoreRelaysButton(onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .height(25.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top + ) { + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = onClick ) { - IconButton( - modifier = Modifier.then(Modifier.size(24.dp)), - onClick = { expanded = true } - ) { - Icon( - imageVector = Icons.Default.ExpandMore, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) } } } @@ -968,6 +1028,7 @@ fun NoteAuthorPicture( if (author == null) { RobohashAsyncImage( robot = "authornotfound", + robotSize = size, contentDescription = stringResource(R.string.unknown_author), modifier = modifier .width(size) @@ -1108,12 +1169,14 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } LaunchedEffect(key1 = note) { - state = DropDownParams( - accountViewModel.isFollowing(note.author), - accountViewModel.isInPrivateBookmarks(note), - accountViewModel.isInPublicBookmarks(note), - accountViewModel.isLoggedUser(note.author) - ) + withContext(Dispatchers.IO) { + state = DropDownParams( + accountViewModel.isFollowing(note.author), + accountViewModel.isInPrivateBookmarks(note), + accountViewModel.isInPublicBookmarks(note), + accountViewModel.isLoggedUser(note.author) + ) + } } DropdownMenu( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index da0f35936..26e83cf91 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -48,9 +48,6 @@ fun PollNote( accountViewModel: AccountViewModel, navController: NavController ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - val zapsState by baseNote.live().zaps.observeAsState() val zappedNote = zapsState?.note ?: return @@ -72,7 +69,7 @@ fun PollNote( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 3.dp) ) { - if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile())) { + if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { ZapVote( baseNote, accountViewModel, @@ -90,7 +87,7 @@ fun PollNote( LinearProgressIndicator( modifier = Modifier.matchParentSize(), color = color, - progress = optionTally + progress = optionTally.toFloat() ) Row( @@ -101,7 +98,7 @@ fun PollNote( modifier = Modifier.padding(horizontal = 10.dp).width(40.dp) ) { Text( - text = "${(optionTally * 100).roundToInt()}%", + text = "${(optionTally.toFloat() * 100).roundToInt()}%", fontWeight = FontWeight.Bold ) } @@ -210,7 +207,7 @@ fun ZapVote( ) .show() } - } else if (zappedNote?.author == account.userProfile()) { + } else if (accountViewModel.isLoggedUser(zappedNote?.author)) { scope.launch { Toast .makeText( 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 e5d3d4cd8..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 @@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.* import java.math.BigDecimal +import java.math.RoundingMode import java.util.* class PollNoteViewModel { @@ -16,7 +17,9 @@ class PollNoteViewModel { var valueMaximum: Int? = null var valueMinimum: Int? = null private var closedAt: Int? = null - var consensusThreshold: Float? = null + var consensusThreshold: BigDecimal? = null + + var totalZapped: BigDecimal = BigDecimal.ZERO fun load(note: Note?) { pollNote = note @@ -24,8 +27,10 @@ class PollNoteViewModel { pollOptions = pollEvent?.pollOptions() valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM) valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM) - consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100) + consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal() closedAt = pollEvent?.getTagInt(CLOSED_AT) + + totalZapped = totalZapped() } fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum @@ -73,47 +78,45 @@ class PollNoteViewModel { return false } - fun optionVoteTally(op: Int): Float { - val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal()) - return if (tally.isNaN()) { // catch div by 0 - 0f - } else { tally } - } - - private fun zappedVoteTotal(): Float { - var total = 0f - pollOptions?.keys?.forEach { - total += zappedPollOptionAmount(it).toFloat() + fun optionVoteTally(op: Int): BigDecimal { + return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { + zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP) + } else { + BigDecimal.ZERO } - return total } fun isPollOptionZappedBy(option: Int, user: User): Boolean { - if (pollNote?.zaps?.any { it.key.author == user } == true) { - pollNote!!.zaps.mapNotNull { it.value?.event } - .filterIsInstance() - .map { - val zappedOption = it.zappedPollOption() - if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) { - return true - } + if (pollNote?.zaps?.any { it.key.author === user } == true) { + pollNote!!.zaps + .any { + val event = it.value?.event as? LnZapEvent + event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex } } return false } fun zappedPollOptionAmount(option: Int): BigDecimal { - return if (pollNote != null) { - pollNote!!.zaps.mapNotNull { it.value?.event } - .filterIsInstance() - .mapNotNull { - val zappedOption = it.zappedPollOption() - if (zappedOption == option) { - it.amount - } else { null } - }.sumOf { it } - } else { - BigDecimal(0) - } + return pollNote?.zaps?.values?.sumOf { + val event = it?.event as? LnZapEvent + if (event?.zappedPollOption() == option) { + event.amount ?: BigDecimal(0) + } else { + BigDecimal(0) + } + } ?: BigDecimal(0) + } + + fun totalZapped(): BigDecimal { + return pollNote?.zaps?.values?.sumOf { + val zapEvent = (it?.event as? LnZapEvent) + + if (zapEvent?.zappedPollOption() != null) { + zapEvent.amount ?: BigDecimal(0) + } else { + BigDecimal(0) + } + } ?: BigDecimal(0) } } 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 5a0d7a3b4..cf9bc9845 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -87,26 +86,28 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) } - Row( - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) { - wantsToReplyTo = baseNote + Spacer(modifier = Modifier.height(8.dp)) + + Row(verticalAlignment = CenterVertically) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ReplyReaction(baseNote, accountViewModel) { + wantsToReplyTo = baseNote + } } - - BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) { - wantsToQuote = baseNote + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + BoostReaction(baseNote, accountViewModel) { + wantsToQuote = baseNote + } + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + LikeReaction(baseNote, accountViewModel) + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ZapReaction(baseNote, accountViewModel) + } + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + ViewCountReaction(baseNote.idHex) } - - LikeReaction(baseNote, accountViewModel, Modifier.weight(1f)) - - ZapReaction(baseNote, accountViewModel, Modifier.weight(1f)) - - ViewCountReaction(baseNote, Modifier.weight(1f)) } } @@ -114,14 +115,12 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { fun ReplyReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, showCounter: Boolean = true, onPress: () -> Unit ) { val repliesState by baseNote.live().replies.observeAsState() val replies = repliesState?.note?.replies ?: emptySet() - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -141,29 +140,32 @@ fun ReplyReaction( } } ) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Modifier.size(15.dp), - tint = grayTint - ) + ReplyIcon() } if (showCounter) { Text( " ${showCount(replies.size)}", fontSize = 14.sp, - color = grayTint, - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } } +@Composable +private fun ReplyIcon() { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) +} + @Composable private fun BoostReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier, onQuotePress: () -> Unit ) { val boostsState by baseNote.live().boosts.observeAsState() @@ -229,16 +231,14 @@ private fun BoostReaction( Text( " ${showCount(boostedNote?.boosts?.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @Composable fun LikeReaction( baseNote: Note, - accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier + accountViewModel: AccountViewModel ) { val reactionsState by baseNote.live().reactions.observeAsState() val reactedNote = reactionsState?.note ?: return @@ -287,8 +287,7 @@ fun LikeReaction( Text( " ${showCount(reactedNote.reactions.size)}", fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = textModifier + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @@ -457,13 +456,13 @@ fun ZapReaction( } @Composable -private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) { +private fun ViewCountReaction(idHex: String) { val uri = LocalUriHandler.current val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) IconButton( modifier = Modifier.size(20.dp), - onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } + onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") } ) { Icon( imageVector = Icons.Outlined.BarChart, @@ -473,10 +472,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) ) } - Row(modifier = textModifier) { + Row() { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") + .data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000") .diskCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.ENABLED) .build(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index e755a8df1..8c2686973 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -20,11 +20,9 @@ import com.vitorpamplona.amethyst.model.* @Composable fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) { - val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - .toSet() - .sortedBy { account.userProfile().isFollowingCached(it) } + val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } - ReplyInformation(replyTo, sortedMentions, account) { + ReplyInformation(replyTo, dupMentions, account) { navController.navigate("User/${it.pubkeyHex}") } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index f9512cde8..7d0094295 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -43,7 +43,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, var isNew by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zapSetCard) { + LaunchedEffect(key1 = zapSetCard.createdAt()) { withContext(Dispatchers.IO) { isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 3c83d0945..f7e1a24c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent @@ -15,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import kotlinx.coroutines.CoroutineScope @@ -30,7 +31,7 @@ import kotlin.time.measureTimedValue class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter) -open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { +open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(CardFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -46,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { @Synchronized private fun refreshSuspended() { - val notes = dataSource.loadTop() + val notes = localFilter.loadTop() - val thisAccount = (dataSource as? NotificationFeedFilter)?.account + val thisAccount = (localFilter as? NotificationFeedFilter)?.account val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) { val newCards = convertToCard(notes.minus(lastNotesCopy)) if (newCards.isNotEmpty()) { lastNotes = notes - lastAccount = (dataSource as? NotificationFeedFilter)?.account + lastAccount = (localFilter as? NotificationFeedFilter)?.account updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) } } else { val cards = convertToCard(notes) lastNotes = notes - lastAccount = (dataSource as? NotificationFeedFilter)?.account + lastAccount = (localFilter as? NotificationFeedFilter)?.account updateFeed(cards) } } - private fun convertToCard(notes: List): List { + private fun convertToCard(notes: Collection): List { val reactionsPerEvent = mutableMapOf>() notes .filter { it.event is ReactionEvent } @@ -159,7 +160,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { CardFeedState.Empty } @@ -172,6 +173,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } } + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + + val thisAccount = (localFilter as? NotificationFeedFilter)?.account + val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null + + if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) { + val filteredNewList = localFilter.applyFilter(newItems) + val actuallyNew = filteredNewList.minus(lastNotesCopy) + + val newCards = convertToCard(actuallyNew) + if (newCards.isNotEmpty()) { + lastNotes = lastNotesCopy + newItems + lastAccount = (localFilter as? NotificationFeedFilter)?.account + updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed()) + } + } else { + // Refresh Everything + refreshSuspended() + } + } + @OptIn(ExperimentalTime::class) private val bundler = BundledUpdate(250, Dispatchers.IO) { // adds the time to perform the refresh into this delay @@ -181,13 +204,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter) : ViewModel() { } Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") } + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) fun invalidateData() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { - invalidateData() + @OptIn(ExperimentalTime::class) + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + val (value, elapsed) = measureTimedValue { + refreshFromOldState(it.flatten().toSet()) + } + Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed") + } + } + + private val cacheListener: (Set) -> Unit = { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } } init { 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 a6b2fcdb1..60daf1072 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 @@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter @@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { fun refreshSuspended() { val notes = newListFromDataSource() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is FeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { FeedState.Empty } } else if (currentState is FeedState.Loaded) { @@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } + fun refreshFromOldState(newItems: Set) { + val oldNotesState = _feedContent.value + if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) { + val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()) + updateFeed(newList) + } else { + // Refresh Everything + refreshSuspended() + } + } + private val bundler = BundledUpdate(250, Dispatchers.IO) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. refreshSuspended() } + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) fun invalidateData() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { - invalidateData() + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { + refreshFromOldState(it.flatten().toSet()) + } + } + + private val cacheListener: (Set) -> Unit = { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } } init { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 9973afab8..8c6806de8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter @@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi private fun refreshSuspended() { val notes = dataSource.loadTop() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is LnZapFeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi private fun updateFeed(notes: List>) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { LnZapFeedState.Empty } } else if (currentState is LnZapFeedState.Loaded) { @@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { + private val cacheListener: (Set) -> Unit = { newNotes -> invalidateData() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 500320035..aae7fd358 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCacheState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter @@ -36,7 +36,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun refreshSuspended() { val notes = dataSource.loadTop() - val oldNotesState = feedContent.value + val oldNotesState = _feedContent.value if (oldNotesState is UserFeedState.Loaded) { // Using size as a proxy for has changed. if (notes != oldNotesState.feed.value) { @@ -50,7 +50,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private fun updateFeed(notes: List) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { - val currentState = feedContent.value + val currentState = _feedContent.value if (notes.isEmpty()) { _feedContent.update { UserFeedState.Empty } } else if (currentState is UserFeedState.Loaded) { @@ -72,7 +72,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { bundler.invalidate() } - private val cacheListener: (LocalCacheState) -> Unit = { + private val cacheListener: (Set) -> Unit = { newNotes -> invalidateData() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index df3289bca..ab92f670d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,10 +20,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter @@ -30,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -57,13 +55,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -80,7 +72,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon } ) } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null) 1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null) 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/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index 6406aced9..b86669ebc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight @@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon @@ -14,7 +17,6 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -37,10 +39,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter @@ -50,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) { val pagerState = rememberPagerState() @@ -68,13 +66,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon Box(Modifier.fillMaxWidth()) { TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -115,7 +107,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon } } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> TabKnown(accountViewModel, navController, markKnownAsRead) 1 -> TabNew(accountViewModel, navController, markNewAsRead) 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/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 436c1b0b0..40dfa3099 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,17 +19,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.UserFeedView import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -45,13 +43,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -61,7 +53,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont } ) } - HorizontalPager(count = 1, state = pagerState) { + HorizontalPager(pageCount = 1, state = pagerState) { when (pagerState.currentPage) { 0 -> UserFeedView(feedViewModel, accountViewModel, navController) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index c191de3aa..af8adb2c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -1,12 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow -import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -23,10 +25,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.PagerState -import com.google.accompanist.pager.pagerTabIndicatorOffset import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter @@ -39,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import kotlinx.coroutines.launch -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( homeFeedViewModel: NostrHomeFeedViewModel, @@ -90,13 +88,7 @@ fun HomeScreen( ) { TabRow( backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - } + selectedTabIndex = pagerState.currentPage ) { Tab( selected = pagerState.currentPage == 0, @@ -114,7 +106,7 @@ fun HomeScreen( } ) } - HorizontalPager(count = 2, state = pagerState) { + HorizontalPager(pageCount = 2, state = pagerState) { when (pagerState.currentPage) { 0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop) 1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop) 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 e0293b5d2..49486609d 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -19,6 +20,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController @@ -34,10 +36,12 @@ import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.currentRoute import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { + val coroutineScope = rememberCoroutineScope() val navController = rememberNavController() val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val sheetState = rememberModalBottomSheetState( @@ -64,6 +68,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun }, drawerContent = { DrawerContent(navController, scaffoldState, sheetState, accountViewModel) + BackHandler(enabled = scaffoldState.drawerState.isOpen) { + coroutineScope.launch { scaffoldState.drawerState.close() } + } }, floatingActionButton = { FloatingButtons(navController, accountStateViewModel) 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 6c832ceac..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 @@ -5,6 +5,8 @@ import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText @@ -48,10 +50,6 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -70,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 @@ -98,7 +97,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigDecimal -@OptIn(ExperimentalPagerApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -187,12 +186,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro ScrollableTabRow( backgroundColor = MaterialTheme.colors.background, selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - }, edgePadding = 8.dp, modifier = Modifier.onSizeChanged { tabsSize = it @@ -268,7 +261,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro } } HorizontalPager( - count = 8, + pageCount = 8, state = pagerState, modifier = with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) @@ -409,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 }) } } @@ -498,7 +492,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode userBadge.acceptedBadges?.let { note -> (note.event as? BadgeProfilesEvent)?.let { event -> - FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { + FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) { event.badgeAwardEvents().forEach { badgeAwardEvent -> val baseNote = LocalCache.notes[badgeAwardEvent] if (baseNote != null) { @@ -655,6 +649,7 @@ fun BadgeThumb( if (image == null) { RobohashAsyncImage( robot = "authornotfound", + robotSize = size, contentDescription = stringResource(R.string.unknown_author), modifier = pictureModifier .width(size) @@ -664,6 +659,7 @@ fun BadgeThumb( } else { RobohashFallbackAsyncImage( robot = note.idHex, + robotSize = size, model = image, contentDescription = stringResource(id = R.string.profile_image), modifier = pictureModifier @@ -712,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/drawable-xxxhdpi/coffee.xml b/app/src/main/res/drawable/coffee.xml similarity index 100% rename from app/src/main/res/drawable-xxxhdpi/coffee.xml rename to app/src/main/res/drawable/coffee.xml diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 81296d243..dc5073f2a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -263,9 +263,30 @@ nsec / hex privát kulcs Hozzájárulás összege sats-ban + Szavazás Létrehozása + Szükséges mezők: + Zap-et kapják + Szavazás elsődleges leírása… + Szavazás %s megoszlása + Szavazás opcióinak leírása + Kiegészítő mezők: + Zap minimum + Zap maximum + Konszenzus + (0–100)% + Szavazás lezárása + napok + A szavazásra nem lehet már új szavazatot leadni + Zap összege + Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett + "%1$s esemény keresése" Nyilvános üzenet hozzáadása Köszönöm a kemény munkát! + + Létrehoz és Hozzáad + A szavazás létrehozója sajátjára nem szavazhat. + #zappoll diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7063684f9..b85757a84 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,4 +226,63 @@ Heeft privésleutel Alleen lezen, geen privésleutel Terug + Spam of oplichting + Godslastering of haatdragend gedrag + Kwaadwillige imitatie + Naaktheid of grafische inhoud + Illegaal gedrag + Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters. + + Misbruik melden + Alle geplaatste rapporteringen zijn openbaar. + Geef optioneel extra context over uw rapportering... + Extra context + Reden + Selecteer een reden… + Rapporteer + Blokkeer en rapporteer + Blokkeren + + Bladwijzers + Privé bladwijzers + PPublieke Bladwijzers + Toevoegen aan privé bladwijzers + Toevoegen aan publieke bladwijzers + Verwijderen uit privé bladwijzers + Verwijderen uit publieke bladwijzers + + Wallet Connect Service + Machtigt een Nostr Secret om zaps te betalen zonder de app te verlaten. Houd het geheim veilig en gebruik indien mogelijk een privé relays. + Wallet Connect publieke sleutel + Wallet Connect Relay + Wallet Connect Secret + Toon secret sleutel + nsec / hex privésleutel + + Bedrag in sats + Verzend poll + Verplichte velden: + Zap ontvangers + Primaire poll beschrijving… + Optie %s + Poll optie beschrijvingen + Optionele velden: + Zap minimum + Zap maximum + Consensus + (0–100)% + Sluit na + dagen + Poll is gesloten voor nieuwe stemmen + Zap bedrag + Slechts één stem per gebruiker is toegestaan bij dit type peiling. + + "Zoeken naar event %1$s" + + Een openbaar bericht toevoegen + Bedankt voor al jullie werk! + + Maken en toevoegen + Poll auteurs kunnen niet stemmen in hun eigen poll. + #zappoll 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 + diff --git a/build.gradle b/build.gradle index ee67b5b0c..347dc9b4d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.6" lifecycle_version = '2.6.1' - compose_ui_version = '1.5.0-alpha01' + compose_ui_version = '1.5.0-alpha02' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' @@ -10,8 +10,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.4.2' apply false - id 'com.android.library' version '7.4.2' apply false + id 'com.android.application' version '8.0.0' apply false + id 'com.android.library' version '8.0.0' apply false id 'org.jetbrains.kotlin.android' version '1.8.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false } diff --git a/gradle.properties b/gradle.properties index c9ecf2f95..e4e68fdb1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,6 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableR8.fullMode=true \ No newline at end of file +android.enableR8.fullMode=true +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c1070955d..289cef284 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME