diff --git a/.gitignore b/.gitignore index f928a5380..cf483fc1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -*.iml -.gradle /local.properties /.idea/caches /.idea/libraries @@ -11,9 +9,7 @@ .DS_Store /build /captures -.externalNativeBuild .cxx -local.properties # Built application files @@ -36,6 +32,7 @@ out/ # release/ # Gradle files +.gradle .gradle/ build/ @@ -48,25 +45,50 @@ proguard/ # Log Files *.log -# Android Studio Navigation editor temp files -.navigation/ - -# Android Studio captures folder +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production captures/ +.navigation/ +*.ipr +*~ +*.swp # IntelliJ *.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml -.idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -# Android Studio 3 in .gitignore file. -.idea/caches -.idea/modules.xml -# Comment next line if keeping position of elements in Navigation Editor is relevant for you -.idea/navEditor.xml +*.iws +/out/ +deploymentTargetDropdown.xml +render.experimental.xml + +# User-specific configurations +.idea/**/caches/ +.idea/**/libraries/ +.idea/**/shelf/ +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/.name +.idea/**/compiler.xml +.idea/**/copyright/profiles_settings.xml +.idea/**/encodings.xml +.idea/**/misc.xml +.idea/**/modules.xml +.idea/**/scopes/scope_settings.xml +.idea/**/dictionaries +.idea/**/vcs.xml +.idea/**/jsLibraryMappings.xml +.idea/**/datasources.xml +.idea/**/dataSources.ids +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/assetWizardSettings.xml +.idea/**/gradle.xml +.idea/**/jarRepositories.xml +.idea/**/navEditor.xml # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index fb7f4a8a4..000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 0477fed22..09cf8b8e4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -80,9 +80,6 @@ dependencies { // Biometrics implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" - // Swipe Refresh - implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha' - // Bitcoin secp256k1 bindings to Android implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1' @@ -100,9 +97,6 @@ dependencies { implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' - // Robohash for Avatars - implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar' - // link preview implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1' @@ -133,29 +127,19 @@ dependencies { // For QR generation implementation 'com.google.zxing:core:3.5.1' - implementation "androidx.camera:camera-camera2:1.2.1" - implementation 'androidx.camera:camera-lifecycle:1.2.1' - implementation 'androidx.camera:camera-view:1.2.1' + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // Markdown implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0" implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0" implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0" - // For QR Scanning - implementation 'com.google.mlkit:vision-common:17.3.0' - - // Local Barcode Scanning model - // The idea is to make it work for degoogled phones - implementation 'com.google.mlkit:barcode-scanning:17.0.3' - // Local model for language identification implementation 'com.google.mlkit:language-id:17.0.4' // Google services model the translate text implementation 'com.google.mlkit:translate:17.0.1' - // Automatic memory leak detection debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 641da01bb..c22a472ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + + \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt new file mode 100644 index 000000000..a2c3f5600 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -0,0 +1,15 @@ +package com.vitorpamplona.amethyst + +import android.app.Application + +class Amethyst : Application() { + override fun onCreate() { + super.onCreate() + instance = this + } + + companion object { + lateinit var instance: Amethyst + private set + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index d43669d7c..bfcea8192 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -1,20 +1,26 @@ package com.vitorpamplona.amethyst -import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey object EncryptedStorage { private const val PREFERENCES_NAME = "secret_keeper" - fun preferences(context: Context): EncryptedSharedPreferences { + fun prefsFileName(npub: String? = null): String { + return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub" + } + + fun preferences(npub: String? = null): EncryptedSharedPreferences { + val context = Amethyst.instance val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() + val preferencesName = prefsFileName(npub) + return EncryptedSharedPreferences.create( context, - PREFERENCES_NAME, + preferencesName, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 83e0f331a..0d60e8885 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -1,6 +1,8 @@ package com.vitorpamplona.amethyst +import android.annotation.SuppressLint import android.content.Context +import android.content.SharedPreferences import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.Account @@ -9,55 +11,188 @@ import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent +import fr.acinq.secp256k1.Hex import nostr.postr.Persona import nostr.postr.toHex +import nostr.postr.toNpub +import java.io.File import java.util.Locale -class LocalPreferences(context: Context) { - private object PrefKeys { - const val NOSTR_PRIVKEY = "nostr_privkey" - const val NOSTR_PUBKEY = "nostr_pubkey" - const val FOLLOWING_CHANNELS = "following_channels" - const val HIDDEN_USERS = "hidden_users" - const val RELAYS = "relays" - const val DONT_TRANSLATE_FROM = "dontTranslateFrom" - const val LANGUAGE_PREFS = "languagePreferences" - const val TRANSLATE_TO = "translateTo" - const val ZAP_AMOUNTS = "zapAmounts" - const val LATEST_CONTACT_LIST = "latestContactList" - const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo" - val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } - } +// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences +// To use plaintext SharedPreferences for debugging, set this to true +// It will only apply in Debug builds +private const val DEBUG_PLAINTEXT_PREFERENCES = false +private const val DEBUG_PREFERENCES_NAME = "debug_prefs" - private val encryptedPreferences = EncryptedStorage.preferences(context) - private val gson = GsonBuilder().create() +data class AccountInfo( + val npub: String, + val hasPrivKey: Boolean, + val current: Boolean, + val displayName: String?, + val profilePicture: String? +) - fun clearEncryptedStorage() { - encryptedPreferences.edit().apply { - encryptedPreferences.all.keys.forEach { remove(it) } +private object PrefKeys { + const val CURRENT_ACCOUNT = "currently_logged_in_account" + const val SAVED_ACCOUNTS = "all_saved_accounts" + const val NOSTR_PRIVKEY = "nostr_privkey" + const val NOSTR_PUBKEY = "nostr_pubkey" + const val DISPLAY_NAME = "display_name" + const val PROFILE_PICTURE_URL = "profile_picture" + const val FOLLOWING_CHANNELS = "following_channels" + const val HIDDEN_USERS = "hidden_users" + const val RELAYS = "relays" + const val DONT_TRANSLATE_FROM = "dontTranslateFrom" + const val LANGUAGE_PREFS = "languagePreferences" + const val TRANSLATE_TO = "translateTo" + const val ZAP_AMOUNTS = "zapAmounts" + const val LATEST_CONTACT_LIST = "latestContactList" + const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo" + val LAST_READ: (String) -> String = { route -> "last_read_route_$route" } +} + +private val gson = GsonBuilder().create() + +object LocalPreferences { + private const val comma = "," + + private var currentAccount: String? + get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + set(npub) { + val prefs = encryptedPreferences() + prefs.edit().apply { + putString(PrefKeys.CURRENT_ACCOUNT, npub) + }.apply() + } + + private val savedAccounts: List + get() = encryptedPreferences() + .getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf() + + private val prefsDirPath: String + get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/" + + private fun addAccount(npub: String) { + val accounts = savedAccounts.toMutableList() + if (npub !in accounts) { + accounts.add(npub) + } + val prefs = encryptedPreferences() + prefs.edit().apply { + putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma)) }.apply() } + private fun setCurrentAccount(account: Account) { + val npub = account.userProfile().pubkeyNpub() + currentAccount = npub + addAccount(npub) + } + + fun switchToAccount(npub: String) { + currentAccount = npub + } + + /** + * Removes the account from the app level shared preferences + */ + private fun removeAccount(npub: String) { + val accounts = savedAccounts.toMutableList() + if (accounts.remove(npub)) { + val prefs = encryptedPreferences() + prefs.edit().apply { + putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma)) + }.apply() + } + } + + /** + * Deletes the npub-specific shared preference file + */ + private fun deleteUserPreferenceFile(npub: String) { + val prefsDir = File(prefsDirPath) + prefsDir.list()?.forEach { + if (it.contains(npub)) { + File(prefsDir, it).delete() + } + } + } + + private fun encryptedPreferences(npub: String? = null): SharedPreferences { + return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { + val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" + Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) + } else { + return EncryptedStorage.preferences(npub) + } + } + + /** + * Clears the preferences for a given npub, deletes the preferences xml file, + * and switches the user to the first account in the list if it exists + * + * We need to use `commit()` to write changes to disk and release the file + * lock so that it can be deleted. If we use `apply()` there is a race + * condition and the file will probably not be deleted + */ + @SuppressLint("ApplySharedPref") + fun updatePrefsForLogout(npub: String) { + val userPrefs = encryptedPreferences(npub) + userPrefs.edit().clear().commit() + removeAccount(npub) + deleteUserPreferenceFile(npub) + + if (savedAccounts.isEmpty()) { + val appPrefs = encryptedPreferences() + appPrefs.edit().clear().apply() + } else if (currentAccount == npub) { + currentAccount = savedAccounts.elementAt(0) + } + } + + fun updatePrefsForLogin(account: Account) { + setCurrentAccount(account) + saveToEncryptedStorage(account) + } + + fun allSavedAccounts(): List { + return savedAccounts.map { npub -> + val prefs = encryptedPreferences(npub) + val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null + + AccountInfo( + npub = npub, + hasPrivKey = hasPrivKey, + current = npub == currentAccount, + displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null), + profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null) + ) + } + } + fun saveToEncryptedStorage(account: Account) { - encryptedPreferences.edit().apply { + val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) + prefs.edit().apply { account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) } account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) } - account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) } - account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) } - account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) } - account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) } - account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) } - account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) } - account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) } - account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) } + putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels) + putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers) + putString(PrefKeys.RELAYS, gson.toJson(account.localRelays)) + putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) + putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences)) + putString(PrefKeys.TRANSLATE_TO, account.translateTo) + putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) + putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo) + putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName()) + putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture()) }.apply() } fun loadFromEncryptedStorage(): Account? { - encryptedPreferences.apply { + encryptedPreferences(currentAccount).apply { + val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) - val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf() val localRelays = gson.fromJson( @@ -75,7 +210,8 @@ class LocalPreferences(context: Context) { val latestContactList = try { getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { - Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent + Event.gson.fromJson(it, Event::class.java) + .getRefinedEvent(true) as ContactListEvent } } catch (e: Throwable) { e.printStackTrace() @@ -84,43 +220,83 @@ class LocalPreferences(context: Context) { val languagePreferences = try { getString(PrefKeys.LANGUAGE_PREFS, null)?.let { - gson.fromJson(it, object : TypeToken>() {}.type) as Map - } ?: mapOf() + gson.fromJson( + it, + object : TypeToken>() {}.type + ) as Map + } ?: mapOf() } catch (e: Throwable) { e.printStackTrace() - mapOf() + mapOf() } val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false) - if (pubKey != null) { - return Account( - Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), - followingChannels, - hiddenUsers, - localRelays, - dontTranslateFrom, - languagePreferences, - translateTo, - zapAmountChoices, - hideDeleteRequestInfo, - latestContactList - ) - } else { - return null - } + return Account( + Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), + followingChannels, + hiddenUsers, + localRelays, + dontTranslateFrom, + languagePreferences, + translateTo, + zapAmountChoices, + hideDeleteRequestInfo, + latestContactList + ) } } fun saveLastRead(route: String, timestampInSecs: Long) { - encryptedPreferences.edit().apply { + encryptedPreferences(currentAccount).edit().apply { putLong(PrefKeys.LAST_READ(route), timestampInSecs) }.apply() } fun loadLastRead(route: String): Long { - encryptedPreferences.run { + encryptedPreferences(currentAccount).run { return getLong(PrefKeys.LAST_READ(route), 0) } } + + fun migrateSingleUserPrefs() { + if (currentAccount != null) return + + val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return + val npub = Hex.decode(pubkey).toNpub() + + val stringPrefs = listOf( + PrefKeys.NOSTR_PRIVKEY, + PrefKeys.NOSTR_PUBKEY, + PrefKeys.RELAYS, + PrefKeys.LANGUAGE_PREFS, + PrefKeys.TRANSLATE_TO, + PrefKeys.ZAP_AMOUNTS, + PrefKeys.LATEST_CONTACT_LIST + ) + + val stringSetPrefs = listOf( + PrefKeys.FOLLOWING_CHANNELS, + PrefKeys.HIDDEN_USERS, + PrefKeys.DONT_TRANSLATE_FROM + ) + + encryptedPreferences().apply { + val appPrefs = this + encryptedPreferences(npub).edit().apply { + val userPrefs = this + + stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) } + stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) } + userPrefs.putBoolean( + PrefKeys.HIDE_DELETE_REQUEST_INFO, + appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false) + ) + }.apply() + } + + encryptedPreferences().edit().clear().apply() + addAccount(npub) + currentAccount = npub + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt index 0173fe3bd..891303243 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt @@ -21,7 +21,7 @@ object NotificationCache { val scope = CoroutineScope(Job() + Dispatchers.IO) scope.launch { - LocalPreferences(context).saveLastRead(route, timestampInSecs) + LocalPreferences.saveLastRead(route, timestampInSecs) live.invalidateData() } } @@ -30,7 +30,7 @@ object NotificationCache { fun load(route: String, context: Context): Long { var lastTime = lastReadByRoute[route] if (lastTime == null) { - lastTime = LocalPreferences(context).loadLastRead(route) + lastTime = LocalPreferences.loadLastRead(route) lastReadByRoute[route] = lastTime } return lastTime diff --git a/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt deleted file mode 100644 index a4d95abd4..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/RoboHashCache.kt +++ /dev/null @@ -1,167 +0,0 @@ -package com.vitorpamplona.amethyst - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.util.LruCache -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing -import name.neuhalfen.projects.android.robohash.handle.Handle -import name.neuhalfen.projects.android.robohash.handle.HandleFactory -import name.neuhalfen.projects.android.robohash.paths.Configuration -import name.neuhalfen.projects.android.robohash.repository.ImageRepository -import java.util.UUID - -object RoboHashCache { - - lateinit var robots: MyRoboHash - - lateinit var defaultAvatar: ImageBitmap - - @Synchronized - fun get(context: Context, hash: String): ImageBitmap { - if (!this::robots.isInitialized) { - robots = MyRoboHash(context) - - defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap() - } - - return defaultAvatar - } -} - -/** - * Recreates RoboHash to use a custom configuration - */ -class MyRoboHash(context: Context) { - private val configuration: Configuration = ModifiedSet1Configuration() - private val repository: ImageRepository - private val hashing = VariableSizeHashing(configuration.bucketSizes) - - // Optional - private var memoryCache: LruCache? = null - - init { - repository = ImageRepository(context.assets) - } - - fun useCache(memoryCache: LruCache?) { - this.memoryCache = memoryCache - } - - fun calculateHandleFromUUID(uuid: UUID?): Handle { - val data = hashing.createBuckets(uuid) - return handleFactory.calculateHandle(data) - } - - fun imageForHandle(handle: Handle): Bitmap { - if (null != memoryCache) { - val cached = memoryCache!![handle.toString()] - if (null != cached) return cached - } - val bucketValues = handle.bucketValues() - val paths = configuration.convertToFacetParts(bucketValues) - val sampleSize = 1 - val buffer = repository.createBuffer(configuration.width(), configuration.height()) - val target = buffer.copy(Bitmap.Config.ARGB_8888, true) - val merged = Canvas(target) - val paint = Paint(0) - - // The first image is not added as copy form the buffer - for (i in paths.indices) { - merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint) - } - repository.returnBuffer(buffer) - if (null != memoryCache) { - memoryCache!!.put(handle.toString(), target) - } - return target - } - - companion object { - private val handleFactory = HandleFactory() - } -} - -/** - * Custom configuration to avoid the use of String.format in the GeneratePath - * This uses the default location and ends up encoding number in the local language - */ -class ModifiedSet1Configuration : Configuration { - override fun convertToFacetParts(bucketValues: ByteArray): Array { - require(bucketValues.size == BUCKET_COUNT) - val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()] - val paths = mutableListOf() - - // e.g. - // blue face #2 - // blue nose #7 - // blue - val firstFacetBucket = BUCKET_COLOR + 1 - for (facet in 0 until FACET_COUNT) { - val bucketValue = bucketValues[firstFacetBucket + facet].toInt() - paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue)) - } - return paths.toTypedArray() - } - - private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String { - // TODO: Make more efficient - return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color) - .replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0')) - } - - override fun getBucketSizes(): ByteArray { - return BUCKET_SIZES - } - - override fun width(): Int { - return 300 - } - - override fun height(): Int { - return 300 - } - - companion object { - private const val ROOT = "sets/set1" - private const val BUCKET_COLOR = 0 - private const val COLOR_COUNT = 10 - private const val BODY_COUNT = 10 - private const val FACE_COUNT = 10 - private const val MOUTH_COUNT = 10 - private const val EYES_COUNT = 10 - private const val ACCESSORY_COUNT = 10 - private const val BUCKET_COUNT = 6 - private const val FACET_COUNT = 5 - private val BUCKET_SIZES = byteArrayOf( - COLOR_COUNT.toByte(), - BODY_COUNT.toByte(), - FACE_COUNT.toByte(), - MOUTH_COUNT.toByte(), - EYES_COUNT.toByte(), - ACCESSORY_COUNT.toByte() - ) - private val INT_TO_COLOR = arrayOf( - "blue", - "brown", - "green", - "grey", - "orange", - "pink", - "purple", - "red", - "white", - "yellow" - ) - private val FACET_PATH_TEMPLATES = arrayOf( - "#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png", - "#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png", - "#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png", - "#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png", - "#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png" - ) - } -} 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 cbe20430e..4fb0f22f3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -27,13 +27,7 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.Relay import fr.acinq.secp256k1.Hex -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import nostr.postr.toNpub import java.io.ByteArrayInputStream import java.time.Instant @@ -55,13 +49,10 @@ object LocalCache { val addressables = ConcurrentHashMap() fun checkGetOrCreateUser(key: String): User? { - return try { - val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex - getOrCreateUser(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create user: $key", e) - null + if (isValidHexNpub(key)) { + return getOrCreateUser(key) } + return null } @Synchronized @@ -77,13 +68,10 @@ object LocalCache { if (ATag.isATag(key)) { return checkGetOrCreateAddressableNote(key) } - return try { - val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex - getOrCreateNote(key) - } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create note: $key", e) - null + if (isValidHexNpub(key)) { + return getOrCreateNote(key) } + return null } @Synchronized @@ -96,12 +84,19 @@ object LocalCache { } fun checkGetOrCreateChannel(key: String): Channel? { + if (isValidHexNpub(key)) { + return getOrCreateChannel(key) + } + return null + } + + private fun isValidHexNpub(key: String): Boolean { return try { - val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex - getOrCreateChannel(key) + Hex.decode(key).toNpub() + true } catch (e: IllegalArgumentException) { - Log.e("LocalCache", "Invalid Key to create channel: $key", e) - null + Log.e("LocalCache", "Invalid Key to create user: $key", e) + false } } @@ -464,21 +459,19 @@ object LocalCache { fun consume(event: ChannelCreateEvent) { // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") - // new event val oldChannel = getOrCreateChannel(event.id) val author = getOrCreateUser(event.pubKey) - if (event.createdAt > oldChannel.updatedMetadataAt) { - if (oldChannel.creator == null || oldChannel.creator == author) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + if (event.createdAt <= oldChannel.updatedMetadataAt) { + return // older data, does nothing + } + if (oldChannel.creator == null || oldChannel.creator == author) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) + val note = getOrCreateNote(event.id) + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList(), emptyList()) - refreshObservers() - } - } else { - // older data, does nothing + refreshObservers() } } @@ -653,7 +646,7 @@ object LocalCache { } fun pruneOldAndHiddenMessages(account: Account) { - channels.forEach { + channels.forEach { it -> val toBeRemoved = it.value.pruneOldAndHiddenMessages(account) toBeRemoved.forEach { @@ -666,7 +659,7 @@ object LocalCache { ?.mapNotNull { checkGetOrCreateUser(it) } // Counts the replies - it.replyTo?.forEach { replyingNote -> + it.replyTo?.forEach { _ -> it.removeReply(it) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt index 28bb298f4..81edc34ae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt @@ -25,10 +25,10 @@ class Nip05Verifier { return null } - fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { val scope = CoroutineScope(Job() + Dispatchers.IO) scope.launch { - fetchNip05JsonSuspend(lnaddress, onSuccess, onError) + fetchNip05JsonSuspend(nip05address, onSuccess, onError) } } @@ -42,7 +42,10 @@ class Nip05Verifier { withContext(Dispatchers.IO) { try { - val request: Request = Request.Builder().url(url).build() + val request = Request.Builder() + .header("User-Agent", "Amethyst") + .url(url) + .build() client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 1a2371805..7beaf1777 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -51,7 +51,10 @@ class LightningAddressResolver { } withContext(Dispatchers.IO) { - val request: Request = Request.Builder().url(url).build() + val request: Request = Request.Builder() + .header("User-Agent", "Amethyst") + .url(url) + .build() client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { @@ -91,7 +94,10 @@ class LightningAddressResolver { url += "&nostr=$encodedNostrRequest" } - val request: Request = Request.Builder().url(url).build() + val request: Request = Request.Builder() + .header("User-Agent", "Amethyst") + .url(url) + .build() client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt index b3da7c803..1b57e095e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/MetadataEvent.kt @@ -128,7 +128,7 @@ class MastodonIdentity( if (proofUrl.isBlank()) return null val path = proofUrl.removePrefix("https://").split("?")[0].split("/") - return MastodonIdentity(path[0], path[1]) + return MastodonIdentity("${path[0]}/${path[1]}", path[2]) } catch (e: Exception) { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 3b132324f..309badbf0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -55,7 +55,10 @@ class Relay( if (socket != null) return try { - val request = Request.Builder().url(url.trim()).build() + val request = Request.Builder() + .header("User-Agent", "Amethyst") + .url(url.trim()) + .build() val listener = object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { 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 cdb3aab35..0cd9b18dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -49,12 +49,14 @@ class MainActivity : FragmentActivity() { .build() } + LocalPreferences.migrateSingleUserPrefs() + setContent { AmethystTheme { // A surface container using the 'background' color from the theme Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { val accountStateViewModel: AccountStateViewModel = viewModel { - AccountStateViewModel(LocalPreferences(applicationContext)) + AccountStateViewModel() } AccountScreen(accountStateViewModel, startingPage) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index 386ab20bf..7f3666703 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -31,6 +31,7 @@ object ImageSaver { val client = OkHttpClient.Builder().build() val request = Request.Builder() + .header("User-Agent", "Amethyst") .get() .url(url) .build() 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 e2fcf31b1..8e23f66a6 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 @@ -43,8 +43,9 @@ object ImageUploader { .build() val request: Request = Request.Builder() - .url("https://api.imgur.com/3/image") .header("Authorization", "Client-ID e6aea87296f3f96") + .header("User-Agent", "Amethyst") + .url("https://api.imgur.com/3/image") .post(requestBody) .build() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt index 86502b820..c96ba3b56 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -186,7 +186,7 @@ fun ServerConfigHeader() { Spacer(modifier = Modifier.size(5.dp)) Text( - text = "Spam", + text = stringResource(R.string.spam), maxLines = 1, fontSize = 14.sp, modifier = Modifier.weight(1f), 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 59e223fe9..ed19bcc92 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 @@ -42,7 +42,7 @@ import java.net.URL import java.util.regex.Pattern val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$") -val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$") +val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$") val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$") val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*") 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 new file mode 100644 index 000000000..28def6986 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt @@ -0,0 +1,288 @@ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import coil.request.ImageRequest +import java.nio.ByteBuffer +import java.security.MessageDigest + +private fun toHex(color: Color): String { + val argb = color.toArgb() + val rgb = argb and 0x00FFFFFF // Mask out the alpha channel + return String.format("#%06X", rgb) +} + +private val sha256: MessageDigest = MessageDigest.getInstance("SHA-256") + +private fun byteMod10(byte: Byte): Int { + val ub = byte.toUByte().toInt() + return ub % 10 +} + +private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color { + return Color(b1.toUByte().toInt(), b2.toUByte().toInt(), b3.toUByte().toInt()) +} + +private fun svgString(msg: String): String { + val hash = sha256.digest(msg.toByteArray()) + val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) } + val bgColor = bytesToRGB(hash[0], hash[1], hash[2]) + val fgColor = bytesToRGB(hash[3], hash[4], hash[5]) + val bodyIndex = byteMod10(hash[6]) + val faceIndex = byteMod10(hash[7]) + val eyesIndex = byteMod10(hash[8]) + val mouthIndex = byteMod10(hash[9]) + val accIndex = byteMod10(hash[10]) + val body = bodies[bodyIndex] + val face = faces[faceIndex] + val eye = eyes[eyesIndex] + val mouth = mouths[mouthIndex] + val accessory = accessories[accIndex] + + return """ + + + + + Robohash $hashHex + ${background}${body.paths}${face.paths}${eye.paths}${mouth.paths}${accessory.paths} + + """.trimIndent() +} + +object Robohash { + fun imageRequest(context: Context, message: String): ImageRequest { + return ImageRequest + .Builder(context) + .data( + ByteBuffer.wrap( + svgString(message).toByteArray() + ) + ) + .crossfade(100) + .build() + } +} + +private data class Part(val style: String, val paths: String) + +private const val background = """""" + +private val accessories: List = listOf( + Part( + """.cls-00-2{fill:none;}.cls-00-2,.cls-00-3,.cls-00-4{stroke:#000;}.cls-00-2,.cls-00-3{stroke-linecap:round;stroke-linejoin:round;}.cls-00-3{fill-opacity:0.4;stroke-width:0.75px;}.cls-00-4{fill-opacity:0.2;stroke-miterlimit:10;stroke-width:0.5px;}""", + """""" + ), + Part( + """.cls-01-2{fill:#fff;fill-opacity:0.2;}.cls-01-3,.cls-01-4{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-01-4{stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-02-2{fill:#be1e2d;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-6,.cls-02-7,.cls-02-9{stroke:#000;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-9{stroke-miterlimit:10;}.cls-02-3{fill:#561317;}.cls-02-4{fill:#ed293b;}.cls-02-11,.cls-02-5,.cls-02-7{fill:none;}.cls-02-5,.cls-02-6{stroke-width:0.75px;}.cls-02-6{fill:#fff;}.cls-02-6,.cls-02-8{fill-opacity:0.2;}.cls-02-6,.cls-02-7{stroke-linecap:round;stroke-linejoin:round;}.cls-02-9{fill:#e6e7e8;}.cls-02-10{fill:#d0d2d3;}""", + """""" + ), + Part( + """.cls-03-2,.cls-03-3,.cls-03-8{fill:#fff;}.cls-03-2,.cls-03-3,.cls-03-7{fill-opacity:0.2;}.cls-03-2,.cls-03-4,.cls-03-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-03-2{stroke-width:0.75px;}.cls-03-4{fill:none;}.cls-03-5{fill:#ec1c24;}.cls-03-6{fill-opacity:0.1;}.cls-03-8{fill-opacity:0.4;}""", + """""" + ), + Part( + """.cls-04-2,.cls-04-3{fill:none;stroke-linecap:round;stroke-linejoin:round;}.cls-04-2,.cls-04-3,.cls-04-5{stroke:#000;}.cls-04-3,.cls-04-5{stroke-width:0.75px;}.cls-04-4,.cls-04-6{fill:#fff;}.cls-04-4{fill-opacity:0.4;}.cls-04-5{fill:#ec1c24;stroke-miterlimit:10;}.cls-04-6,.cls-04-7{fill-opacity:0.2;}""", + """""" + ), + Part( + """.cls-05-2{fill:#fff;fill-opacity:0.4;}.cls-05-2,.cls-05-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-05-3{fill:none;stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-06-2,.cls-06-4{fill-opacity:0.2;}.cls-06-3,.cls-06-6{fill:none;}.cls-06-3,.cls-06-5,.cls-06-6{stroke:#000;}.cls-06-3,.cls-06-5{stroke-miterlimit:10;}.cls-06-4{fill:#fff;}.cls-06-5{fill:#ec1c24;stroke-width:0.75px;}.cls-06-6{stroke-linecap:round;stroke-linejoin:round;}""", + """""" + ), + Part( + """.cls-07-10,.cls-07-2,.cls-07-4,.cls-07-8,.cls-07-9{fill:none;}.cls-07-10,.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke:#000;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8{stroke-linecap:round;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke-linejoin:round;}.cls-07-3,.cls-07-5{fill:#fff;}.cls-07-3,.cls-07-5,.cls-07-6,.cls-07-7{fill-opacity:0.2;}.cls-07-3,.cls-07-4{stroke-width:0.75px;}.cls-07-10,.cls-07-8,.cls-07-9{stroke-width:1.5px;}.cls-07-10{stroke-miterlimit:10;}""", + """""" + ), + Part( + """.cls-08-2{fill:none;}.cls-08-2,.cls-08-3,.cls-08-5,.cls-08-6,.cls-08-7{stroke:#000;stroke-miterlimit:10;}.cls-08-3,.cls-08-4{fill:#fff;}.cls-08-3,.cls-08-4,.cls-08-8{fill-opacity:0.2;}.cls-08-5{fill:#716558;}.cls-08-6{fill:#9a8479;}.cls-08-7{fill:#c1b49a;}""", + """""" + ), + Part( + """.cls-09-2{fill:none;}.cls-09-2,.cls-09-4{stroke:#000;stroke-miterlimit:10;}.cls-09-3,.cls-09-4{fill:#fff;}.cls-09-3{fill-opacity:0.2;}.cls-09-5{fill:#d0d2d3;}.cls-09-6{fill-opacity:0.4;}""", + """""" + ) +) + +private val bodies: List = listOf( + Part( + """.cls-10-2{fill:#fff;fill-opacity:0.4;}.cls-10-3,.cls-10-5,.cls-10-7{fill:none;}.cls-10-3,.cls-10-4,.cls-10-5,.cls-10-6,.cls-10-7,.cls-10-8{stroke:#000;stroke-miterlimit:10;}.cls-10-3{stroke-width:1.2px;}.cls-10-4,.cls-10-6,.cls-10-9{fill-opacity:0.2;}.cls-10-5{stroke-width:1.25px;}.cls-10-6{stroke-width:0.75px;}.cls-10-8{fill:#6d6e70;}""", + """""" + ), + Part( + """.cls-11-2{fill:#fff;}.cls-11-2,.cls-11-6{fill-opacity:0.2;}.cls-11-3{fill:none;}.cls-11-3,.cls-11-4{stroke:#000;stroke-miterlimit:10;}.cls-11-4{fill:#6d6e70;}.cls-11-5{opacity:0.2;}""", + """""" + ), + Part( + """.cls-12-2{fill:#fff;fill-opacity:0.4;}.cls-12-3,.cls-12-7{fill:none;}.cls-12-3,.cls-12-5,.cls-12-6,.cls-12-7{stroke:#000;}.cls-12-3,.cls-12-5,.cls-12-6{stroke-miterlimit:10;}.cls-12-4,.cls-12-6{fill-opacity:0.2;}.cls-12-5{fill:#6d6e70;}.cls-12-7{stroke-linecap:round;stroke-linejoin:round;}""", + """""" + ), + Part( + """.cls-13-2{fill:none;}.cls-13-2,.cls-13-5{stroke:#000;stroke-miterlimit:10;}.cls-13-3{fill:#fff;fill-opacity:0.4;}.cls-13-4{fill-opacity:0.2;}.cls-13-5{fill:#6d6e70;}""", + """""" + ), + Part( + """.cls-14-2{fill:none;}.cls-14-2,.cls-14-4{stroke:#000;stroke-miterlimit:10;}.cls-14-3,.cls-14-6{fill-opacity:0.2;}.cls-14-4{fill:#6d6e70;}.cls-14-5,.cls-14-6{fill:#fff;}.cls-14-5{fill-opacity:0.4;}""", + """""" + ), + Part( + """.cls-15-2{fill:#fff;}.cls-15-2,.cls-15-4{fill-opacity:0.2;}.cls-15-2,.cls-15-3,.cls-15-5{stroke:#000;stroke-miterlimit:10;}.cls-15-3{fill:none;}.cls-15-5{fill:#6d6e70;}""", + """""" + ), + Part( + """.cls-16-2,.cls-16-5{fill:none;}.cls-16-2,.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke:#000;}.cls-16-2{stroke-linecap:round;stroke-linejoin:round;}.cls-16-3,.cls-16-6,.cls-16-7{fill-opacity:0.2;}.cls-16-4,.cls-16-6{fill:#fff;}.cls-16-4{fill-opacity:0.4;}.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke-miterlimit:10;}.cls-16-8{fill:#f9ec31;}.cls-16-9{fill:#6d6e70;}""", + """""" + ), + Part( + """.cls-17-2{fill:#020202;}.cls-17-2,.cls-17-4,.cls-17-6,.cls-17-7{fill-opacity:0.4;}.cls-17-3{fill-opacity:0.2;}.cls-17-4,.cls-17-6{fill:#fff;}.cls-17-4,.cls-17-5,.cls-17-8{stroke:#000;stroke-miterlimit:10;}.cls-17-5{fill:none;}.cls-17-8{fill:#6d6e70;}""", + """""" + ), + Part( + """.cls-fill-2,.cls-18-3,.cls-18-5,.cls-18-6{stroke:#000;stroke-miterlimit:10;}.cls-18-3{fill:none;}.cls-18-4{fill:#fff;}.cls-18-4,.cls-18-5{fill-opacity:0.4;}.cls-18-6{fill:#6d6e70;}.cls-18-7{opacity:0.2;}.cls-18-8{fill-opacity:0.2;}""", + """""" + ), + Part( + """.cls-19-2{fill-opacity:0.6;}.cls-19-11,.cls-19-13,.cls-19-14,.cls-19-2,.cls-19-3,.cls-19-4,.cls-19-6,.cls-19-8{stroke:#000;}.cls-19-11,.cls-19-13,.cls-19-2,.cls-19-4,.cls-19-6,.cls-19-8{stroke-miterlimit:10;}.cls-19-11,.cls-19-14,.cls-19-3,.cls-19-8{fill:none;}.cls-19-14,.cls-19-3{stroke-linecap:round;stroke-linejoin:round;}.cls-19-10,.cls-19-12,.cls-19-4,.cls-19-5{fill:#fff;}.cls-19-12,.cls-19-4{fill-opacity:0.2;}.cls-19-4{stroke-opacity:0;}.cls-19-5{fill-opacity:0.1;}.cls-19-6{fill:#6d6e70;}.cls-19-7{fill:#58595b;}.cls-19-13,.cls-19-9{fill-opacity:0.4;}.cls-19-10{fill-opacity:0.5;}.cls-19-11,.cls-19-14{stroke-width:0.75px;}""", + """""" + ) +) + +private val eyes: List = listOf( + Part( + """.cls-20-2{fill-opacity:0.4;}.cls-20-2,.cls-20-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-20-3{fill:#461917;stroke-width:0.5px;}""", + """""" + ), + Part( + """.cls-21-2{fill-opacity:0.2;}.cls-21-2,.cls-21-3,.cls-21-4,.cls-21-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-21-2,.cls-21-4,.cls-21-5{stroke-width:0.5px;}.cls-21-3,.cls-21-4{fill-opacity:0.4;}.cls-21-5{fill:#461917;}.cls-21-6{fill:#faaf40;}""", + """""" + ), + Part( + """.cls-22-2{opacity:0.4;}.cls-22-3{fill:#461917;}.cls-22-3,.cls-22-4,.cls-22-5{stroke:#000;}.cls-22-3,.cls-22-5{stroke-linecap:round;stroke-linejoin:round;}.cls-22-3,.cls-22-4{stroke-width:0.5px;}.cls-22-4{fill:#ec1c24;stroke-miterlimit:10;}.cls-22-5{fill:none;stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-23-2,.cls-23-3{fill:#fff;}.cls-23-2{fill-opacity:0.4;}.cls-23-3{fill-opacity:0.2;}.cls-23-3,.cls-23-4,.cls-23-5{stroke:#000;stroke-miterlimit:10;}.cls-23-4{fill:none;}.cls-23-4,.cls-23-5{stroke-width:0.75px;}.cls-23-5{fill:red;}""", + """""" + ), + Part( + """.cls-24-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-24-2,.cls-24-3,.cls-24-5{stroke:#000;}.cls-24-2,.cls-24-5{stroke-width:0.75px;}.cls-24-3,.cls-24-5{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-24-3{stroke-width:0.5px;}.cls-24-4{fill:#ec1c24;}""", + """""" + ), + Part( + """.cls-25-2{fill-opacity:0.55;stroke-miterlimit:10;stroke-width:0.75px;}.cls-25-2,.cls-25-3{stroke:#000;}.cls-25-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}.cls-25-4{fill:#ec1c24;}""", + """""" + ), + Part( + """.cls-26-2{fill-opacity:0.6;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-5,.cls-26-6{stroke:#000;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-6{stroke-linecap:round;stroke-linejoin:round;}.cls-26-3{fill:#461917;}.cls-26-3,.cls-26-4,.cls-26-5{stroke-width:0.5px;}.cls-26-4,.cls-26-5{fill:#f9ec31;}.cls-26-5{stroke-miterlimit:10;}.cls-26-6{fill:none;}""", + """""" + ), + Part( + """.cls-27-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-27-2,.cls-27-3,.cls-27-4{stroke:#000;}.cls-27-3,.cls-27-4{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-27-3{stroke-width:0.5px;}.cls-27-4{stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-28-2{fill:none;}.cls-28-2,.cls-28-3,.cls-28-4,.cls-28-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-28-2,.cls-28-4,.cls-28-6{stroke-width:0.75px;}.cls-28-3{fill:#461917;stroke-width:0.5px;}.cls-28-4{fill-opacity:0.4;}.cls-28-5{fill:#fff100;}.cls-28-6{fill:#fff;fill-opacity:0.2;}""", + """""" + ), + Part( + """.cls-29-2{fill:#fff;}.cls-29-2,.cls-29-4{fill-opacity:0.4;}.cls-29-3{fill:none;}.cls-29-3,.cls-29-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-29-4{stroke-width:0.75px;}""", + """""" + ) +) + +private val faces: List = listOf( + Part( + """.cls-30-2{fill:#fff;fill-opacity:0.4;}.cls-30-3,.cls-30-4{fill:none;}.cls-30-3,.cls-30-4,.cls-30-6{stroke:#000;}.cls-30-3,.cls-30-6{stroke-linecap:round;stroke-linejoin:round;}.cls-30-4{stroke-miterlimit:10;}.cls-30-5,.cls-30-6{fill-opacity:0.2;}.cls-30-6{stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-31-2,.cls-31-4{fill-opacity:0.2;}.cls-31-3{fill:none;}.cls-31-3,.cls-31-4{stroke:#000;stroke-miterlimit:10;}.cls-31-4,.cls-31-5{fill:#fff;}.cls-31-4{stroke-width:0.75px;}.cls-31-5{fill-opacity:0.4;}""", + """""" + ), + Part( + """.cls-32-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-32-3{fill-opacity:0.2;}.cls-32-4{fill:#fff;fill-opacity:0.4;}""", + """""" + ), + Part( + """.cls-33-2{fill-opacity:0.2;}.cls-33-3{fill:#fff;fill-opacity:0.4;}.cls-33-4{fill:none;stroke:#000;stroke-miterlimit:10;}""", + """""" + ), + Part( + """.cls-34-2,.cls-34-3{fill:#fff;}.cls-34-2{fill-opacity:0.4;}.cls-34-3,.cls-34-5{fill-opacity:0.2;}.cls-34-3,.cls-34-4{stroke:#000;stroke-miterlimit:10;}.cls-34-4{fill:none;}""", + """""" + ), + Part( + """.cls-35-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-35-3{fill-opacity:0.2;}.cls-35-4{fill:#fff;fill-opacity:0.4;}""", + """""" + ), + Part( + """.cls-36-2,.cls-36-6{fill:#fff;}.cls-36-2{fill-opacity:0.4;}.cls-36-3,.cls-36-4{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-36-3{stroke-width:2px;}.cls-36-5,.cls-36-6{fill-opacity:0.2;}""", + """""" + ), + Part( + """.cls-37-2{fill:#fff;fill-opacity:0.4;}.cls-37-3{fill:none;}.cls-37-3,.cls-37-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-37-4{fill-opacity:0.2;}""", + """""" + ), + Part( + """.cls-38-2{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-38-3,.cls-38-6{fill:#fff;}.cls-38-3,.cls-38-4{fill-opacity:0.2;}.cls-38-5{fill-opacity:0.1;}.cls-38-6{fill-opacity:0.4;}""", + """""" + ), + Part( + """.cls-39-2{fill:#fff;fill-opacity:0.4;}.cls-39-3{fill:none;}.cls-39-3,.cls-39-4{stroke:#000;stroke-miterlimit:10;}.cls-39-4{fill-opacity:0.2;stroke-width:0.75px;}""", + """""" + ) +) + +private val mouths: List = listOf( + Part( + """.cls-40-2{fill-opacity:0.4;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}""", + """""" + ), + Part( + """.cls-41-2,.cls-41-4{fill:none;}.cls-41-2,.cls-41-3,.cls-41-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-41-3{fill-opacity:0.4;}.cls-41-3,.cls-41-4{stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-42-2{fill-opacity:0.4;}.cls-42-2,.cls-42-5,.cls-42-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-42-3{fill:#ee4036;}.cls-42-4{fill:#f05a28;}.cls-42-5{fill:#faaf40;stroke-width:0.75px;}.cls-42-6{fill:none;}""", + """""" + ), + Part( + """.cls-43-2{fill:#f9ec31;}.cls-43-3{fill:#faaf40;}.cls-43-4,.cls-43-6{fill:none;}.cls-43-4,.cls-43-5,.cls-43-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-43-5{fill-opacity:0.4;}.cls-43-5,.cls-43-6{stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-44-2{fill:none;}.cls-44-2,.cls-44-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-44-3{opacity:0.4;}.cls-44-4{fill:#461917;}.cls-44-5{fill-opacity:0.4;stroke-width:0.75px;}""", + """""" + ), + Part( + """.cls-45-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-45-2,.cls-45-3{stroke:#000;}.cls-45-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}""", + """""" + ), + Part( + """.cls-46-2{fill-opacity:0.4;}.cls-46-3{fill:none;}.cls-46-3,.cls-46-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-46-4{fill:#461917;stroke-width:0.5px;}""", + """""" + ), + Part( + """.cls-47-2{fill-opacity:0.4;}.cls-47-2,.cls-47-3,.cls-47-4,.cls-47-5{stroke:#000;stroke-miterlimit:10;}.cls-47-3{fill:#f6921e;}.cls-47-4{fill:#f9ec31;stroke-width:0.75px;}.cls-47-5{fill:none;}""", + """""" + ), + Part( + """.cls-48-2{opacity:0.4;}.cls-48-3,.cls-48-4,.cls-48-5{fill:none;}.cls-48-3,.cls-48-4,.cls-48-5,.cls-48-6,.cls-48-7,.cls-48-8{stroke:#000;}.cls-48-3,.cls-48-5,.cls-48-7{stroke-miterlimit:10;}.cls-48-4,.cls-48-6,.cls-48-8{stroke-linecap:round;stroke-linejoin:round;}.cls-48-4,.cls-48-5{stroke-width:0.5px;}.cls-48-6{fill:#f6921e;stroke-width:1.2px;}.cls-48-7{fill:#f9ec31;}.cls-48-7,.cls-48-8{stroke-width:0.75px;}.cls-48-8{fill:#f05a27;}""", + """""" + ), + Part( + """.cls-49-2{fill:none;}.cls-49-2,.cls-49-3,.cls-49-5{stroke:#000;}.cls-49-2,.cls-49-3{stroke-linecap:round;stroke-linejoin:round;}.cls-49-3{opacity:0.1;}.cls-49-4{opacity:0.4;}.cls-49-5{fill-opacity:0.4;stroke-miterlimit:10;stroke-width:0.75px;}""", + """""" + ) +) 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 new file mode 100644 index 000000000..73b500ccf --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -0,0 +1,109 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +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 coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter + +@Composable +fun RobohashAsyncImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, + onState: ((AsyncImagePainter.State) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + 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 + ) +} + +@Composable +fun RobohashFallbackAsyncImage( + robot: String, + model: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality +) { + val context = LocalContext.current + val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot)) + + AsyncImage( + model = model, + contentDescription = contentDescription, + modifier = modifier, + placeholder = painter, + fallback = painter, + error = painter, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) +} + +@Composable +fun RobohashAsyncImageProxy( + robot: String, + model: ResizeImage, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality +) { + if (model.url == null) { + RobohashAsyncImage( + robot = robot, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } else { + RobohashFallbackAsyncImage( + robot = robot, + model = model.proxyUrl(), + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + filterQuality = filterQuality + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt new file mode 100644 index 000000000..eeb90bdcc --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -0,0 +1,214 @@ +package com.vitorpamplona.amethyst.ui.navigation + +import androidx.compose.foundation.clickable +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.Spacer +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.note.toShortenHex +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage +import nostr.postr.bechToBytes +import nostr.postr.toHex + +@Composable +fun AccountSwitchBottomSheet( + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel +) { + val context = LocalContext.current + val accounts = LocalPreferences.allSavedAccounts() + + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val accountUserState by account.userProfile().live().metadata.observeAsState() + val accountUser = accountUserState?.user ?: return + + var popupExpanded by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + + Column(modifier = Modifier.verticalScroll(scrollState)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) + } + accounts.forEach { acc -> + val current = accountUser.pubkeyNpub() == acc.npub + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .weight(1f) + .clickable { + accountStateViewModel.switchUser(acc.npub) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .padding(16.dp, 16.dp) + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(55.dp) + .padding(0.dp) + ) { + RobohashAsyncImageProxy( + robot = acc.npub.bechToBytes("npub").toHex(), + model = ResizeImage(acc.profilePicture, 55.dp), + contentDescription = stringResource(R.string.profile_image), + modifier = Modifier + .width(55.dp) + .height(55.dp) + .clip(shape = CircleShape) + ) + Box( + modifier = Modifier + .size(20.dp) + .align(Alignment.TopEnd) + ) { + if (acc.hasPrivKey) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = stringResource(R.string.account_switch_has_private_key), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.primary + ) + } else { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = stringResource(R.string.account_switch_pubkey_only), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.primary + ) + } + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + val npubShortHex = acc.npub.toShortenHex() + + if (acc.displayName != null && acc.displayName != npubShortHex) { + Text(acc.displayName) + } + + Text(npubShortHex) + } + Column(modifier = Modifier.width(32.dp)) { + if (current) { + Icon( + imageVector = Icons.Default.RadioButtonChecked, + contentDescription = stringResource(R.string.account_switch_active_account), + tint = MaterialTheme.colors.secondary + ) + } + } + } + } + + IconButton( + onClick = { accountStateViewModel.logOff(acc.npub) } + ) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = stringResource(R.string.log_out), + tint = MaterialTheme.colors.onSurface + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { popupExpanded = true }) { + Text(stringResource(R.string.account_switch_add_account_btn)) + } + } + } + + if (popupExpanded) { + Dialog( + onDismissRequest = { popupExpanded = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Box { + LoginPage(accountStateViewModel, isFirstLogin = false) + TopAppBar( + title = { Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) }, + navigationIcon = { + IconButton(onClick = { popupExpanded = false }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colors.onSurface + ) + } + }, + backgroundColor = Color.Transparent, + elevation = 0.dp + ) + } + } + } + } +} 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 f59437670..5d68ddba0 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 @@ -84,8 +84,8 @@ fun keyboardAsState(): State { @Composable fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) { val currentRoute = currentRoute(navController) + val currentRouteBase = currentRoute?.substringBefore("?") val coroutineScope = rememberCoroutineScope() - val isKeyboardOpen by keyboardAsState() if (isKeyboardOpen == Keyboard.Closed) { @@ -99,13 +99,15 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView backgroundColor = MaterialTheme.colors.background ) { bottomNavigationItems.forEach { item -> + val selected = currentRouteBase == item.base + BottomNavigationItem( - icon = { NotifiableIcon(item, currentRoute, accountViewModel) }, - selected = currentRoute == item.route, + icon = { NotifiableIcon(item, selected, accountViewModel) }, + selected = selected, onClick = { coroutineScope.launch { - if (currentRoute != item.route) { - navController.navigate(item.route) { + if (currentRouteBase != item.base) { + navController.navigate(item.base) { navController.graph.startDestinationRoute?.let { start -> popUpTo(start) restoreState = true @@ -114,8 +116,8 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView restoreState = true } } else { - // TODO: Make it scrool to the top - navController.navigate(item.route) { + val route = currentRoute.replace("{scrollToTop}", "true") + navController.navigate(route) { navController.graph.startDestinationRoute?.let { start -> popUpTo(start) { inclusive = item.route == Route.Home.route } restoreState = true @@ -135,13 +137,13 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView } @Composable -private fun NotifiableIcon(item: Route, currentRoute: String?, accountViewModel: AccountViewModel) { - Box(Modifier.size(if ("Home" == item.route) 25.dp else 23.dp)) { +private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { + Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { Icon( - painter = painterResource(id = item.icon), + painter = painterResource(id = route.icon), null, - modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp), - tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified + modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp), + tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified ) val accountState by accountViewModel.accountLiveData.observeAsState() @@ -160,13 +162,13 @@ private fun NotifiableIcon(item: Route, currentRoute: String?, accountViewModel: LaunchedEffect(key1 = notif) { withContext(Dispatchers.IO) { - hasNewItems = item.hasNewItems(account, notif.cache, context) + hasNewItems = route.hasNewItems(account, notif.cache, context) } } LaunchedEffect(key1 = db) { withContext(Dispatchers.IO) { - hasNewItems = item.hasNewItems(account, notif.cache, context) + hasNewItems = route.hasNewItems(account, notif.cache, context) } } 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 76e7164ed..95f475004 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,12 +1,33 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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 import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen +@OptIn(ExperimentalPagerApi::class) @Composable fun AppNavigation( navController: NavHostController, @@ -14,9 +35,86 @@ fun AppNavigation( accountStateViewModel: AccountStateViewModel, nextPage: String? = null ) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + GlobalFeedFilter.account = account + HomeNewThreadFeedFilter.account = account + HomeConversationsFeedFilter.account = account + + val globalFeedViewModel: NostrGlobalFeedViewModel = viewModel() + val homeFeedViewModel: NostrHomeFeedViewModel = viewModel() + val homeRepliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel() + val homePagerState = rememberPagerState() + NavHost(navController, startDestination = Route.Home.route) { - Routes.forEach { - composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, accountStateViewModel, navController)) + Route.Search.let { route -> + composable(route.route, route.arguments, content = { + SearchScreen( + accountViewModel = accountViewModel, + feedViewModel = globalFeedViewModel, + navController = navController, + scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false + ) + }) + } + + Route.Home.let { route -> + composable(route.route, route.arguments, content = { + HomeScreen( + accountViewModel = accountViewModel, + navController = navController, + homeFeedViewModel = homeFeedViewModel, + repliesFeedViewModel = homeRepliesFeedViewModel, + pagerState = homePagerState, + scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false + ) + }) + } + + composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, navController) }) + composable(Route.Notification.route, content = { NotificationScreen(accountViewModel, navController) }) + composable(Route.Filters.route, content = { FiltersScreen(accountViewModel, navController) }) + + Route.Profile.let { route -> + composable(route.route, route.arguments, content = { + ProfileScreen( + userId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + navController = navController + ) + }) + } + + Route.Note.let { route -> + composable(route.route, route.arguments, content = { + ThreadScreen( + noteId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + navController = navController + ) + }) + } + + Route.Room.let { route -> + composable(route.route, route.arguments, content = { + ChatroomScreen( + userId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + navController = navController + ) + }) + } + + Route.Channel.let { route -> + composable(route.route, route.arguments, content = { + ChannelScreen( + channelId = it.arguments?.getString("id"), + accountViewModel = accountViewModel, + accountStateViewModel = accountStateViewModel, + navController = navController + ) + }) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 55e2c376b..eed7bfd60 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -1,257 +1,253 @@ -package com.vitorpamplona.amethyst.ui.navigation - -import android.util.Log -import androidx.compose.foundation.clickable -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.fillMaxHeight -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.shape.CircleShape -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ScaffoldState -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.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.painter.BitmapPainter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavHostController -import coil.Coil -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.NostrAccountDataSource -import com.vitorpamplona.amethyst.service.NostrChannelDataSource -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource -import com.vitorpamplona.amethyst.service.NostrGlobalDataSource -import com.vitorpamplona.amethyst.service.NostrHomeDataSource -import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource -import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource -import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource -import com.vitorpamplona.amethyst.service.NostrThreadDataSource -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource -import com.vitorpamplona.amethyst.service.relays.Client -import com.vitorpamplona.amethyst.service.relays.RelayPool -import com.vitorpamplona.amethyst.ui.actions.NewRelayListView -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy -import com.vitorpamplona.amethyst.ui.components.ResizeImage -import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.launch - -@Composable -fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { - when (currentRoute(navController)) { - // Route.Profile.route -> TopBarWithBackButton(navController) - else -> MainTopBar(scaffoldState, accountViewModel) - } -} - -@Composable -fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - val accountUserState by account.userProfile().live().metadata.observeAsState() - val accountUser = accountUserState?.user ?: return - - val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() } - val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState() - val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState() - - val coroutineScope = rememberCoroutineScope() - - val context = LocalContext.current - val ctx = LocalContext.current.applicationContext - - var wantsToEditRelays by remember { - mutableStateOf(false) - } - - if (wantsToEditRelays) { - NewRelayListView({ wantsToEditRelays = false }, account) - } - - Column() { - TopAppBar( - elevation = 0.dp, - backgroundColor = Color(0xFFFFFF), - title = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box(Modifier) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(start = 0.dp, end = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - IconButton( - onClick = { - Client.allSubscriptions().map { - "$it ${ - Client.getSubscriptionFilters(it) - .joinToString { it.filter.toJson() } - }" - }.forEach { - Log.d("STATE DUMP", it) - } - - NostrAccountDataSource.printCounter() - NostrChannelDataSource.printCounter() - NostrChatroomDataSource.printCounter() - NostrChatroomListDataSource.printCounter() - - NostrGlobalDataSource.printCounter() - NostrHomeDataSource.printCounter() - - NostrSingleEventDataSource.printCounter() - NostrSearchEventOrUserDataSource.printCounter() - NostrSingleChannelDataSource.printCounter() - NostrSingleUserDataSource.printCounter() - NostrThreadDataSource.printCounter() - - NostrUserProfileDataSource.printCounter() - - Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) - - val imageLoader = Coil.imageLoader(context) - Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") - Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") - - Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) - Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) - } - ) { - Icon( - painter = painterResource(R.drawable.amethyst), - null, - modifier = Modifier.size(40.dp), - tint = Color.Unspecified - ) - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), - horizontalAlignment = Alignment.End - - ) { - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}", - color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.clickable( - onClick = { - wantsToEditRelays = true - } - ) - ) - } - } - } - } - }, - navigationIcon = { - IconButton( - onClick = { - coroutineScope.launch { - scaffoldState.drawerState.open() - } - }, - modifier = Modifier - ) { - AsyncImageProxy( - model = ResizeImage(accountUser.profilePicture(), 34.dp), - placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)), - fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)), - error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(34.dp) - .height(34.dp) - .clip(shape = CircleShape) - ) - } - }, - actions = { - IconButton( - onClick = { wantsToEditRelays = true }, - modifier = Modifier - ) { - Icon( - painter = painterResource(R.drawable.ic_trends), - null, - modifier = Modifier.size(24.dp), - tint = Color.Unspecified - ) - } - } - ) - Divider(thickness = 0.25.dp) - } -} - -@Composable -fun TopBarWithBackButton(navController: NavHostController) { - Column() { - TopAppBar( - elevation = 0.dp, - backgroundColor = Color(0xFFFFFF), - title = {}, - navigationIcon = { - IconButton( - onClick = { - navController.popBackStack() - }, - modifier = Modifier - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colors.primary - ) - } - }, - actions = {} - ) - Divider(thickness = 0.25.dp) - } -} +package com.vitorpamplona.amethyst.ui.navigation + +import android.util.Log +import androidx.compose.foundation.clickable +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.fillMaxHeight +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.shape.CircleShape +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ScaffoldState +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import coil.Coil +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.NostrAccountDataSource +import com.vitorpamplona.amethyst.service.NostrChannelDataSource +import com.vitorpamplona.amethyst.service.NostrChatroomDataSource +import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource +import com.vitorpamplona.amethyst.service.NostrGlobalDataSource +import com.vitorpamplona.amethyst.service.NostrHomeDataSource +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource +import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource +import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource +import com.vitorpamplona.amethyst.service.NostrThreadDataSource +import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.amethyst.service.relays.RelayPool +import com.vitorpamplona.amethyst.ui.actions.NewRelayListView +import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch + +@Composable +fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { + when (currentRoute(navController)) { + // Route.Profile.route -> TopBarWithBackButton(navController) + else -> MainTopBar(scaffoldState, accountViewModel) + } +} + +@Composable +fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val accountUserState by account.userProfile().live().metadata.observeAsState() + val accountUser = accountUserState?.user ?: return + + val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() } + val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState() + val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState() + + val coroutineScope = rememberCoroutineScope() + + val context = LocalContext.current + val ctx = LocalContext.current.applicationContext + + var wantsToEditRelays by remember { + mutableStateOf(false) + } + + if (wantsToEditRelays) { + NewRelayListView({ wantsToEditRelays = false }, account) + } + + Column() { + TopAppBar( + elevation = 0.dp, + backgroundColor = Color(0xFFFFFF), + title = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(Modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(start = 0.dp, end = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + IconButton( + onClick = { + Client.allSubscriptions().map { + "$it ${ + Client.getSubscriptionFilters(it) + .joinToString { it.filter.toJson() } + }" + }.forEach { + Log.d("STATE DUMP", it) + } + + NostrAccountDataSource.printCounter() + NostrChannelDataSource.printCounter() + NostrChatroomDataSource.printCounter() + NostrChatroomListDataSource.printCounter() + + NostrGlobalDataSource.printCounter() + NostrHomeDataSource.printCounter() + + NostrSingleEventDataSource.printCounter() + NostrSearchEventOrUserDataSource.printCounter() + NostrSingleChannelDataSource.printCounter() + NostrSingleUserDataSource.printCounter() + NostrThreadDataSource.printCounter() + + NostrUserProfileDataSource.printCounter() + + Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays()) + + val imageLoader = Coil.imageLoader(context) + Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") + Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") + + Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) + Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) + } + ) { + Icon( + painter = painterResource(R.drawable.amethyst), + null, + modifier = Modifier.size(40.dp), + tint = Color.Unspecified + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalAlignment = Alignment.End + + ) { + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}", + color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + style = MaterialTheme.typography.subtitle1, + modifier = Modifier.clickable( + onClick = { + wantsToEditRelays = true + } + ) + ) + } + } + } + } + }, + navigationIcon = { + IconButton( + onClick = { + coroutineScope.launch { + scaffoldState.drawerState.open() + } + }, + modifier = Modifier + ) { + RobohashAsyncImageProxy( + robot = accountUser.pubkeyHex, + model = ResizeImage(accountUser.profilePicture(), 34.dp), + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(34.dp) + .height(34.dp) + .clip(shape = CircleShape) + ) + } + }, + actions = { + IconButton( + onClick = { wantsToEditRelays = true }, + modifier = Modifier + ) { + Icon( + painter = painterResource(R.drawable.ic_trends), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + } + } + ) + Divider(thickness = 0.25.dp) + } +} + +@Composable +fun TopBarWithBackButton(navController: NavHostController) { + Column() { + TopAppBar( + elevation = 0.dp, + backgroundColor = Color(0xFFFFFF), + title = {}, + navigationIcon = { + IconButton( + onClick = { + navController.popBackStack() + }, + modifier = Modifier + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colors.primary + ) + } + }, + actions = {} + ) + Divider(thickness = 0.25.dp) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 09cb7c69d..2c6146e9e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ScaffoldState import androidx.compose.material.Surface import androidx.compose.material.Text @@ -33,7 +35,6 @@ 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.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -46,22 +47,21 @@ import androidx.navigation.NavHostController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterialApi::class) @Composable fun DrawerContent( navController: NavHostController, scaffoldState: ScaffoldState, - accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel + sheetState: ModalBottomSheetState, + accountViewModel: AccountViewModel ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -88,10 +88,10 @@ fun DrawerContent( account.userProfile(), navController, scaffoldState, + sheetState, modifier = Modifier .fillMaxWidth() - .weight(1F), - accountStateViewModel, + .weight(1f), account ) @@ -135,12 +135,10 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol } Column(modifier = modifier) { - AsyncImageProxy( + RobohashAsyncImageProxy( + robot = accountUser.pubkeyHex, model = ResizeImage(accountUser.profilePicture(), 100.dp), contentDescription = stringResource(id = R.string.profile_image), - placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)), - fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)), - error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)), modifier = Modifier .width(100.dp) .height(100.dp) @@ -214,15 +212,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol } } +@OptIn(ExperimentalMaterialApi::class) @Composable fun ListContent( accountUser: User?, navController: NavHostController, scaffoldState: ScaffoldState, + sheetState: ModalBottomSheetState, modifier: Modifier, - accountViewModel: AccountStateViewModel, account: Account ) { + val coroutineScope = rememberCoroutineScope() var backupDialogOpen by remember { mutableStateOf(false) } Column(modifier = modifier.fillMaxHeight()) { @@ -260,10 +260,10 @@ fun ListContent( Spacer(modifier = Modifier.weight(1f)) IconRow( - stringResource(R.string.log_out), - R.drawable.ic_logout, - MaterialTheme.colors.onBackground, - onClick = { accountViewModel.logOff() } + title = stringResource(R.string.drawer_accounts), + icon = R.drawable.manage_accounts, + tint = MaterialTheme.colors.onBackground, + onClick = { coroutineScope.launch { sheetState.show() } } ) } 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 66bed57fa..3ee743e87 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 @@ -4,8 +4,6 @@ import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.currentBackStackEntryAsState @@ -16,105 +14,80 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen sealed class Route( val route: String, val icon: Int, val hasNewItems: (Account, NotificationCache, Context) -> Boolean = { _, _, _ -> false }, - val arguments: List = emptyList(), - val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit + val arguments: List = emptyList() ) { + val base: String + get() = route.substringBefore("?") + object Home : Route( - "Home", - R.drawable.ic_home, - hasNewItems = { acc, cache, ctx -> homeHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } } + route = "Home?scrollToTop={scrollToTop}", + icon = R.drawable.ic_home, + arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }), + hasNewItems = { accountViewModel, cache, context -> homeHasNewItems(accountViewModel, cache, context) } ) + object Search : Route( - "Search", - R.drawable.ic_globe, - buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) } } + route = "Search?scrollToTop={scrollToTop}", + icon = R.drawable.ic_globe, + arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }) ) + object Notification : Route( - "Notification", - R.drawable.ic_notifications, - hasNewItems = { acc, cache, ctx -> notificationHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) } } + route = "Notification", + icon = R.drawable.ic_notifications, + hasNewItems = { accountViewModel, cache, context -> + notificationHasNewItems(accountViewModel, cache, context) + } ) object Message : Route( - "Message", - R.drawable.ic_dm, - hasNewItems = { acc, cache, ctx -> messagesHasNewItems(acc, cache, ctx) }, - buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) } } + route = "Message", + icon = R.drawable.ic_dm, + hasNewItems = { accountViewModel, cache, context -> + messagesHasNewItems(accountViewModel, cache, context) + } ) object Filters : Route( - "Filters", - R.drawable.ic_security, - buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) } } + route = "Filters", + icon = R.drawable.ic_security ) object Profile : Route( - "User/{id}", - R.drawable.ic_profile, - arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) } } + route = "User/{id}", + icon = R.drawable.ic_profile, + arguments = listOf(navArgument("id") { type = NavType.StringType }) ) object Note : Route( - "Note/{id}", - R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) } } + route = "Note/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }) ) object Room : Route( - "Room/{id}", - R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) } } + route = "Room/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }) ) object Channel : Route( - "Channel/{id}", - R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }), - buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) } } + route = "Channel/{id}", + icon = R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType }) ) } -val Routes = listOf( - // bottom - Route.Home, - Route.Message, - Route.Search, - Route.Notification, - - // drawer - Route.Profile, - Route.Note, - Route.Room, - Route.Channel, - Route.Filters -) - // ** // * Functions below only exist because we have not broken the datasource classes into backend and frontend. // ** @Composable -public fun currentRoute(navController: NavHostController): String? { +fun currentRoute(navController: NavHostController): String? { val navBackStackEntry by navController.currentBackStackEntryAsState() return navBackStackEntry?.destination?.route } @@ -124,18 +97,32 @@ private fun homeHasNewItems(account: Account, cache: NotificationCache, context: HomeNewThreadFeedFilter.account = account - return (HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime + return ( + HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() + ?: 0 + ) > lastTime } -private fun notificationHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean { +private fun notificationHasNewItems( + account: Account, + cache: NotificationCache, + context: Context +): Boolean { val lastTime = cache.load("Notification", context) NotificationFeedFilter.account = account - return (NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime + return ( + NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() + ?: 0 + ) > lastTime } -private fun messagesHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean { +private fun messagesHasNewItems( + account: Account, + cache: NotificationCache, + context: Context +): Boolean { ChatroomListKnownFeedFilter.account = account val note = ChatroomListKnownFeedFilter.feed().firstOrNull { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index c38037e9b..9be25b43f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -27,8 +27,6 @@ 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.painter.BitmapPainter -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -43,14 +41,13 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -100,13 +97,8 @@ fun ChatroomCompose( } ChannelName( + channelIdHex = channel.idHex, channelPicture = channel.profilePicture(), - channelPicturePlaceholder = BitmapPainter( - RoboHashCache.get( - context, - channel.idHex - ) - ), channelTitle = { Text( text = buildAnnotatedString { @@ -188,8 +180,8 @@ fun ChatroomCompose( @Composable fun ChannelName( + channelIdHex: String, channelPicture: String?, - channelPicturePlaceholder: Painter?, channelTitle: @Composable (Modifier) -> Unit, channelLastTime: Long?, channelLastContent: String?, @@ -198,11 +190,9 @@ fun ChannelName( ) { ChannelName( channelPicture = { - AsyncImageProxy( + RobohashAsyncImageProxy( + robot = channelIdHex, model = ResizeImage(channelPicture, 55.dp), - placeholder = channelPicturePlaceholder, - fallback = channelPicturePlaceholder, - error = channelPicturePlaceholder, contentDescription = stringResource(R.string.channel_image), modifier = Modifier .width(55.dp) 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 a4a619c1c..0c7a730bb 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 @@ -40,7 +40,6 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -51,17 +50,16 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers @@ -149,12 +147,14 @@ fun ChatroomMessageCompose( val modif = if (innerQuote) { Modifier.padding(top = 10.dp, end = 5.dp) } else { - Modifier.fillMaxWidth(1f).padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp - ) + Modifier + .fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp + ) } Row( @@ -182,9 +182,11 @@ fun ChatroomMessageCompose( var bubbleSize by remember { mutableStateOf(IntSize.Zero) } Column( - modifier = Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged { - bubbleSize = it - } + modifier = Modifier + .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) + .onSizeChanged { + bubbleSize = it + } ) { val authorState by note.author!!.live().metadata.observeAsState() val author = authorState?.user!! @@ -195,11 +197,9 @@ fun ChatroomMessageCompose( horizontalArrangement = alignment, modifier = Modifier.padding(top = 5.dp) ) { - AsyncImageProxy( + RobohashAsyncImageProxy( + robot = author.pubkeyHex, model = ResizeImage(author.profilePicture(), 25.dp), - placeholder = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)), - fallback = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)), - error = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)), contentDescription = stringResource(id = R.string.profile_image), modifier = Modifier .width(25.dp) @@ -307,11 +307,16 @@ fun ChatroomMessageCompose( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.padding(top = 5.dp).then( - with(LocalDensity.current) { - Modifier.widthIn(bubbleSize.width.toDp(), availableBubbleSize.width.toDp()) - } - ) + modifier = Modifier + .padding(top = 5.dp) + .then( + with(LocalDensity.current) { + Modifier.widthIn( + bubbleSize.width.toDp(), + availableBubbleSize.width.toDp() + ) + } + ) ) { Row() { Text( @@ -339,7 +344,7 @@ fun ChatroomMessageCompose( } } - NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } } } @@ -365,18 +370,16 @@ private fun RelayBadges(baseNote: Note) { .size(15.dp) .padding(1.dp) ) { - AsyncImage( + RobohashFallbackAsyncImage( + robot = "https://$url/favicon.ico", model = "https://$url/favicon.ico", - placeholder = BitmapPainter(RoboHashCache.get(ctx, url)), - fallback = BitmapPainter(RoboHashCache.get(ctx, url)), - error = BitmapPainter(RoboHashCache.get(ctx, url)), contentDescription = stringResource(id = R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), modifier = Modifier .fillMaxSize(1f) .clip(shape = CircleShape) .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://" + url) }) + .clickable(onClick = { uri.openUri("https://$url") }) ) } } 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 eb0f19734..8c61fb254 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 @@ -19,7 +19,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -38,7 +37,6 @@ import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -53,9 +51,11 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader @@ -217,11 +217,9 @@ fun NoteCompose( .height(30.dp) .align(Alignment.BottomEnd) ) { - AsyncImageProxy( + RobohashAsyncImageProxy( + robot = channel.idHex, model = ResizeImage(channel.profilePicture(), 30.dp), - placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)), - fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)), - error = BitmapPainter(RoboHashCache.get(context, channel.idHex)), contentDescription = stringResource(R.string.group_picture), modifier = Modifier .width(30.dp) @@ -612,11 +610,9 @@ private fun RelayBadges(baseNote: Note) { .size(15.dp) .padding(1.dp) ) { - AsyncImage( + RobohashFallbackAsyncImage( + robot = "https://$url/favicon.ico", model = "https://$url/favicon.ico", - placeholder = BitmapPainter(RoboHashCache.get(ctx, url)), - fallback = BitmapPainter(RoboHashCache.get(ctx, url)), - error = BitmapPainter(RoboHashCache.get(ctx, url)), contentDescription = stringResource(R.string.relay_icon), colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), modifier = Modifier @@ -686,8 +682,8 @@ fun NoteAuthorPicture( .height(size) ) { if (author == null) { - Image( - painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")), + RobohashAsyncImage( + robot = "authornotfound", contentDescription = stringResource(R.string.unknown_author), modifier = pictureModifier .fillMaxSize(1f) @@ -733,12 +729,10 @@ fun UserPicture( .width(size) .height(size) ) { - AsyncImageProxy( + RobohashAsyncImageProxy( + robot = user.pubkeyHex, model = ResizeImage(user.profilePicture(), size), contentDescription = stringResource(id = R.string.profile_image), - placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), modifier = pictureModifier .fillMaxSize(1f) .clip(shape = CircleShape) @@ -839,7 +833,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) { Divider() DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) { - Text("Request Deletion") + Text(stringResource(R.string.request_deletion)) } } if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt index 1d3aa37d4..5be23dc40 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt @@ -1,156 +1,59 @@ -package com.vitorpamplona.amethyst.ui.qrcode -import android.Manifest -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageProxy -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.* -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.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import com.google.mlkit.vision.barcode.BarcodeScannerOptions -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage -import com.vitorpamplona.amethyst.service.nip19.Nip19 -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -@Composable -fun QrCodeScanner(onScan: (String) -> Unit) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } - - val cameraExecutor = Executors.newSingleThreadExecutor() - - var hasCameraPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - ) - } - - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { granted -> - hasCameraPermission = granted - } - ) - - val analyzer = QRCodeAnalyzer { result -> - result?.let { - try { - val nip19 = Nip19.uriToRoute(it) - val startingPage = when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - else -> null - } - - if (startingPage != null) { - onScan(startingPage) - } - } catch (e: Throwable) { - // QR can be anythign. do not throw errors. - } - } - } - - DisposableEffect(key1 = true) { - launcher.launch(Manifest.permission.CAMERA) - onDispose() { - cameraProviderFuture.get().unbindAll() - cameraExecutor.shutdown() - } - } - - Column() { - if (hasCameraPermission) { - AndroidView( - factory = { context -> - val previewView = PreviewView(context) - - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - bindPreview(analyzer, previewView, cameraExecutor, cameraProvider, lifecycleOwner) - }, ContextCompat.getMainExecutor(context)) - - return@AndroidView previewView - }, - modifier = Modifier.weight(1f) - ) - } - } -} - -fun bindPreview( - analyzer: ImageAnalysis.Analyzer, - previewView: PreviewView, - cameraExecutor: ExecutorService, - cameraProvider: ProcessCameraProvider, - lifecycleOwner: LifecycleOwner -) { - val preview = Preview.Builder().build() - - val selector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - - preview.setSurfaceProvider(previewView.surfaceProvider) - - val imageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - - imageAnalysis.setAnalyzer( - cameraExecutor, - analyzer - ) - - cameraProvider.bindToLifecycle( - lifecycleOwner, - selector, - imageAnalysis, - preview - ) -} - -class QRCodeAnalyzer( - private val onQrCodeScanned: (result: String?) -> Unit -) : ImageAnalysis.Analyzer { - - private val scanningOptions = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build() - - fun scanBarcodes(inputImage: InputImage) { - BarcodeScanning.getClient(scanningOptions).process(inputImage) - .addOnSuccessListener { barcodes -> - if (barcodes.isNotEmpty()) { - onQrCodeScanned(barcodes[0].displayValue) - } - } - .addOnFailureListener { - it.printStackTrace() - } - } - - @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) - override fun analyze(imageProxy: ImageProxy) { - imageProxy.image?.let { image -> - val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees) - scanBarcodes(inputImage) - } - imageProxy.close() - } -} +package com.vitorpamplona.amethyst.ui.qrcode + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import com.google.zxing.client.android.Intents +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.nip19.Nip19 + +@Composable +fun QrCodeScanner(onScan: (String?) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + + val parseQrResult = { it: String -> + try { + val nip19 = Nip19.uriToRoute(it) + val startingPage = when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + else -> null + } + + if (startingPage != null) { + onScan(startingPage) + } else { + onScan(null) + } + } catch (e: Throwable) { + // QR can be anything, do not throw errors. + onScan(null) + } + } + + val qrLauncher = + rememberLauncherForActivityResult(ScanContract()) { + if (it.contents != null) { + parseQrResult(it.contents) + } else { + onScan(null) + } + } + + val scanOptions = ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(stringResource(id = R.string.point_to_the_qr_code)) + setBeepEnabled(false) + setOrientationLocked(false) + addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) + } + + DisposableEffect(lifecycleOwner) { + qrLauncher.launch(scanOptions) + onDispose { } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index eb61f2ede..2884b931d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -1,174 +1,153 @@ -package com.vitorpamplona.amethyst.ui.navigation - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -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.graphics.Color -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -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 com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy -import com.vitorpamplona.amethyst.ui.components.ResizeImage -import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner - -@Composable -fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { - var presenting by remember { mutableStateOf(true) } - - val ctx = LocalContext.current.applicationContext - - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .background(MaterialTheme.colors.background) - .fillMaxSize() - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = onClose) - } - - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - if (presenting) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) - ) { - } - - Column(modifier = Modifier.fillMaxWidth()) { - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - AsyncImageProxy( - model = ResizeImage(user.profilePicture(), 100.dp), - placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)), - contentDescription = stringResource(R.string.profile_image), - modifier = Modifier - .width(100.dp) - .height(100.dp) - .clip(shape = CircleShape) - .border(3.dp, MaterialTheme.colors.background, CircleShape) - .background(MaterialTheme.colors.background) - ) - } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text( - user.bestDisplayName() ?: "", - modifier = Modifier.padding(top = 7.dp), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - } - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text(" @${user.bestUsername()}", color = Color.LightGray) - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp) - ) { - QrCodeDrawer("nostr:${user.pubkeyNpub()}") - } - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) - ) { - Button( - onClick = { presenting = false }, - shape = RoundedCornerShape(35.dp), - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.scan_qr)) - } - } - } else { - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.point_to_the_qr_code), - modifier = Modifier.padding(top = 7.dp), - fontWeight = FontWeight.Bold, - fontSize = 25.sp - ) - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(30.dp) - ) { - QrCodeScanner(onScan) - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp) - ) { - Button( - onClick = { presenting = true }, - shape = RoundedCornerShape(35.dp), - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.show_qr)) - } - } - } - } - } - } - } -} +package com.vitorpamplona.amethyst.ui.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner + +@Composable +fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { + var presenting by remember { mutableStateOf(true) } + + val ctx = LocalContext.current.applicationContext + + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .background(MaterialTheme.colors.background) + .fillMaxSize() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = onClose) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + if (presenting) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 30.dp, vertical = 10.dp) + ) { + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + RobohashAsyncImageProxy( + robot = user.pubkeyHex, + model = ResizeImage(user.profilePicture(), 100.dp), + contentDescription = stringResource(R.string.profile_image), + modifier = Modifier + .width(100.dp) + .height(100.dp) + .clip(shape = CircleShape) + .border(3.dp, MaterialTheme.colors.background, CircleShape) + .background(MaterialTheme.colors.background) + ) + } + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text( + user.bestDisplayName() ?: "", + modifier = Modifier.padding(top = 7.dp), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + Text(" @${user.bestUsername()}", color = Color.LightGray) + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 35.dp, vertical = 10.dp) + ) { + QrCodeDrawer("nostr:${user.pubkeyNpub()}") + } + } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 30.dp, vertical = 10.dp) + ) { + Button( + onClick = { presenting = false }, + shape = RoundedCornerShape(35.dp), + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.scan_qr)) + } + } + } else { + QrCodeScanner { + if (it.isNullOrEmpty()) { + presenting = true + } else { + onScan(it) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index d82d6a353..f96914f78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen +import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage @Composable fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) { @@ -17,7 +18,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is AccountState.LoggedOff -> { - LoginPage(accountStateViewModel) + LoginPage(accountStateViewModel, isFirstLogin = true) } is AccountState.LoggedIn -> { MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index e57a4ae1b..10cc60808 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -17,7 +17,7 @@ import nostr.postr.Persona import nostr.postr.bechToBytes import java.util.regex.Pattern -class AccountStateViewModel(private val localPreferences: LocalPreferences) : ViewModel() { +class AccountStateViewModel() : ViewModel() { private val _accountContent = MutableStateFlow(AccountState.LoggedOff) val accountContent = _accountContent.asStateFlow() @@ -26,10 +26,14 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi // Keeps it in the the UI thread to void blinking the login page. // viewModelScope.launch(Dispatchers.IO) { - localPreferences.loadFromEncryptedStorage()?.let { + tryLoginExistingAccount() + // } + } + + private fun tryLoginExistingAccount() { + LocalPreferences.loadFromEncryptedStorage()?.let { login(it) } - // } } fun login(key: String) { @@ -47,18 +51,25 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi Account(Persona(Hex.decode(key))) } - localPreferences.saveToEncryptedStorage(account) - + LocalPreferences.updatePrefsForLogin(account) login(account) } + fun switchUser(npub: String) { + prepareLogoutOrSwitch() + LocalPreferences.switchToAccount(npub) + tryLoginExistingAccount() + } + fun newKey() { val account = Account(Persona()) - localPreferences.saveToEncryptedStorage(account) + LocalPreferences.updatePrefsForLogin(account) login(account) } fun login(account: Account) { + LocalPreferences.updatePrefsForLogin(account) + if (account.loggedIn.privKey != null) { _accountContent.update { AccountState.LoggedIn(account) } } else { @@ -77,11 +88,11 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { GlobalScope.launch(Dispatchers.IO) { - localPreferences.saveToEncryptedStorage(it.account) + LocalPreferences.saveToEncryptedStorage(it.account) } } - fun logOff() { + private fun prepareLogoutOrSwitch() { val state = accountContent.value when (state) { @@ -99,7 +110,11 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi } _accountContent.update { AccountState.LoggedOff } + } - localPreferences.clearEncryptedStorage() + fun logOff(npub: String) { + prepareLogoutOrSwitch() + LocalPreferences.updatePrefsForLogout(npub) + tryLoginExistingAccount() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index df4fa0ebf..9b77d4156 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -2,22 +2,26 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.unit.dp import androidx.navigation.NavController -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.BoostSetCompose import com.vitorpamplona.amethyst.ui.note.LikeSetCompose @@ -27,40 +31,31 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.ZapSetCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String) { val feedState by viewModel.feedContent.collectAsState() - var isRefreshing by remember { mutableStateOf(false) } - val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + var refreshing by remember { mutableStateOf(false) } + val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.refresh() - isRefreshing = false - } - } - - SwipeRefresh( - state = swipeRefreshState, - onRefresh = { - isRefreshing = true - } - ) { + Box(Modifier.pullRefresh(pullRefreshState)) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is CardFeedState.Empty -> { FeedEmpty { - isRefreshing = true + refreshing = true } } is CardFeedState.FeedError -> { FeedError(state.errorMessage) { - isRefreshing = true + refreshing = true } } is CardFeedState.Loaded -> { + refreshing = false FeedLoaded( state, accountViewModel, @@ -74,6 +69,8 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode } } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index 6763b4d7c..8ba6eb059 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -21,12 +20,12 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?, onWantsToReply: (Note) -> Unit) { +fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String, onWantsToReply: (Note) -> Unit) { val feedState by viewModel.feedContent.collectAsState() var isRefreshing by remember { mutableStateOf(false) } - val listState = rememberLazyListState() + val listState = rememberForeverLazyListState(routeForLastRead) LaunchedEffect(isRefreshing) { if (isRefreshing) { @@ -63,8 +62,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode reverseLayout = true, state = listState ) { - var previousDate: String = "" - itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item -> + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController, onWantsToReply = onWantsToReply) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index 73f3e399b..72a3223f5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -2,11 +2,16 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -14,20 +19,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.note.ChatroomCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun ChatroomListFeedView( viewModel: FeedViewModel, @@ -37,24 +42,11 @@ fun ChatroomListFeedView( ) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - var isRefreshing by remember { mutableStateOf(false) } - val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + var refreshing by remember { mutableStateOf(false) } + val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - val coroutineScope = rememberCoroutineScope() - - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.refresh() - isRefreshing = false - } - } - - SwipeRefresh( - state = swipeRefreshState, - onRefresh = { - isRefreshing = true - } - ) { + Box(Modifier.pullRefresh(pullRefreshState)) { Column() { Crossfade( targetState = feedState, @@ -63,17 +55,18 @@ fun ChatroomListFeedView( when (state) { is FeedState.Empty -> { FeedEmpty { - isRefreshing = true + refreshing = true } } is FeedState.FeedError -> { FeedError(state.errorMessage) { - isRefreshing = true + refreshing = true } } is FeedState.Loaded -> { + refreshing = false FeedLoaded(state, accountViewModel, navController, markAsRead) } @@ -83,6 +76,8 @@ fun ChatroomListFeedView( } } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } @@ -105,11 +100,9 @@ private fun FeedLoaded( if (markAsRead.value) { for (note in state.feed.value) { note.event?.let { - var route = "" val channel = note.channel() - - if (channel != null) { - route = "Channel/${channel.idHex}" + val route = if (channel != null) { + "Channel/${channel.idHex}" } else { val replyAuthorBase = (note.event as? PrivateDmEvent) @@ -122,7 +115,7 @@ private fun FeedLoaded( userToComposeOn = replyAuthorBase } } - route = "Room/${userToComposeOn.pubkeyHex}" + "Room/${userToComposeOn.pubkeyHex}" } notificationCache.cache.markAsRead(route, it.createdAt(), context) @@ -142,7 +135,7 @@ private fun FeedLoaded( itemsIndexed( state.feed.value, key = { index, item -> if (index == 0) index else item.idHex } - ) { index, item -> + ) { _, item -> ChatroomCompose( item, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index be08602fb..10453c6df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight @@ -11,8 +12,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Button +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.OutlinedButton import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -25,64 +30,65 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun FeedView( viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, - routeForLastRead: String? + routeForLastRead: String?, + scrollStateKey: String? = null, + scrollToTop: Boolean = false ) { val feedState by viewModel.feedContent.collectAsState() - var isRefreshing by remember { mutableStateOf(false) } - val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + var refreshing by remember { mutableStateOf(false) } + val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.refresh() - isRefreshing = false - } - } - - SwipeRefresh( - state = swipeRefreshState, - onRefresh = { - isRefreshing = true - } - ) { - Column() { - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + Box(Modifier.pullRefresh(pullRefreshState)) { + Column { + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100) + ) { state -> when (state) { is FeedState.Empty -> { FeedEmpty { - isRefreshing = true + refreshing = true } } + is FeedState.FeedError -> { FeedError(state.errorMessage) { - isRefreshing = true + refreshing = true } } + is FeedState.Loaded -> { + refreshing = false FeedLoaded( state, routeForLastRead, accountViewModel, - navController + navController, + scrollStateKey, + scrollToTop ) } + is FeedState.Loading -> { LoadingFeed() } } } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } @@ -91,9 +97,21 @@ private fun FeedLoaded( state: FeedState.Loaded, routeForLastRead: String?, accountViewModel: AccountViewModel, - navController: NavController + navController: NavController, + scrollStateKey: String?, + scrollToTop: Boolean = false ) { - val listState = rememberLazyListState() + val listState = if (scrollStateKey != null) { + rememberForeverLazyListState(scrollStateKey) + } else { + rememberLazyListState() + } + + if (scrollToTop) { + LaunchedEffect(Unit) { + listState.scrollToItem(index = 0) + } + } LazyColumn( contentPadding = PaddingValues( @@ -102,7 +120,7 @@ private fun FeedLoaded( ), state = listState ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item -> + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> NoteCompose( item, isBoostedNote = false, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt new file mode 100644 index 000000000..0a4a2d5cf --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt @@ -0,0 +1,41 @@ +package com.vitorpamplona.amethyst.ui.screen + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.saveable.rememberSaveable +import com.vitorpamplona.amethyst.ui.navigation.Route + +private val savedScrollStates = mutableMapOf() +private data class ScrollState(val index: Int, val scrollOffset: Int) + +object ScrollStateKeys { + const val GLOBAL_SCREEN = "Global" + val HOME_FOLLOWS = Route.Home.base + "Follows" + val HOME_REPLIES = Route.Home.base + "FollowsReplies" +} + +@Composable +fun rememberForeverLazyListState( + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0 +): LazyListState { + val scrollState = rememberSaveable(saver = LazyListState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset + LazyListState( + savedIndex, + savedOffset + ) + } + DisposableEffect(Unit) { + onDispose { + val lastIndex = scrollState.firstVisibleItemIndex + val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset) + } + } + return scrollState +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt index 4570bef66..d4d8767bd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -2,59 +2,54 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.unit.dp import androidx.navigation.NavController -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { val feedState by viewModel.feedContent.collectAsState() - var isRefreshing by remember { mutableStateOf(false) } - val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + var refreshing by remember { mutableStateOf(false) } + val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.refresh() - isRefreshing = false - } - } - - SwipeRefresh( - state = swipeRefreshState, - onRefresh = { - isRefreshing = true - } - ) { + Box(Modifier.pullRefresh(pullRefreshState)) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is LnZapFeedState.Empty -> { FeedEmpty { - isRefreshing = true + refreshing = true } } is LnZapFeedState.FeedError -> { FeedError(state.errorMessage) { - isRefreshing = true + refreshing = true } } is LnZapFeedState.Loaded -> { + refreshing = false LnZapFeedLoaded(state, accountViewModel, navController) } is LnZapFeedState.Loading -> { @@ -63,6 +58,8 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo } } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt index 66ce40f18..6b1833a65 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt @@ -1,24 +1,28 @@ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState @@ -101,6 +105,7 @@ class RelayFeedViewModel : ViewModel() { } } +@OptIn(ExperimentalMaterialApi::class) @Composable fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() @@ -108,9 +113,6 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo val feedState by viewModel.feedContent.collectAsState() - var isRefreshing by remember { mutableStateOf(false) } - val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) - var wantsToAddRelay by remember { mutableStateOf("") } @@ -119,19 +121,11 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay) } - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.refresh() - isRefreshing = false - } - } + var refreshing by remember { mutableStateOf(false) } + val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - SwipeRefresh( - state = swipeRefreshState, - onRefresh = { - isRefreshing = true - } - ) { + Box(Modifier.pullRefresh(pullRefreshState)) { Column() { val listState = rememberLazyListState() @@ -153,5 +147,7 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo } } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index fc40ed0ad..d9a5a667b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -14,12 +17,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -42,8 +49,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import coil.compose.AsyncImage -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent @@ -56,48 +61,40 @@ import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu +import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.delay +@OptIn(ExperimentalMaterialApi::class) @Composable fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { val feedState by viewModel.feedContent.collectAsState() - var isRefreshing by remember { mutableStateOf(false) } - val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) - val listState = rememberLazyListState() - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.refresh() - isRefreshing = false - } - } + var refreshing by remember { mutableStateOf(false) } + val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - SwipeRefresh( - state = swipeRefreshState, - onRefresh = { - isRefreshing = true - } - ) { + Box(Modifier.pullRefresh(pullRefreshState)) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is FeedState.Empty -> { FeedEmpty { - isRefreshing = true + refreshing = true } } is FeedState.FeedError -> { FeedError(state.errorMessage) { - isRefreshing = true + refreshing = true } } is FeedState.Loaded -> { + refreshing = false LaunchedEffect(noteId) { // waits to load the thread to scroll to item. delay(100) @@ -160,6 +157,8 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A } } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } @@ -187,6 +186,7 @@ fun Modifier.drawReplyLevel(level: Int, color: Color, selected: Color): Modifier } .padding(start = (2 + (level * 3)).dp) +@OptIn(ExperimentalFoundationApi::class) @Composable fun NoteMaster( baseNote: Note, @@ -211,6 +211,8 @@ fun NoteMaster( val noteEvent = note?.event + var popupExpanded by remember { mutableStateOf(false) } + if (noteEvent == null) { BlankNote() } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { @@ -314,7 +316,14 @@ fun NoteMaster( } } - Row(modifier = Modifier.padding(horizontal = 12.dp)) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp) + .combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + ) + ) { Column() { val eventContent = note.event?.content() @@ -343,5 +352,7 @@ fun NoteMaster( } } } + + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index ed9b04b7c..3452677c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -2,59 +2,54 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.unit.dp import androidx.navigation.NavController -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { val feedState by viewModel.feedContent.collectAsState() - var isRefreshing by remember { mutableStateOf(false) } - val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + var refreshing by remember { mutableStateOf(false) } + val refresh = { refreshing = true; viewModel.refresh(); refreshing = false } + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.refresh() - isRefreshing = false - } - } - - SwipeRefresh( - state = swipeRefreshState, - onRefresh = { - isRefreshing = true - } - ) { + Box(Modifier.pullRefresh(pullRefreshState)) { Column() { Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is UserFeedState.Empty -> { FeedEmpty { - isRefreshing = true + refreshing = true } } is UserFeedState.FeedError -> { FeedError(state.errorMessage) { - isRefreshing = true + refreshing = true } } is UserFeedState.Loaded -> { + refreshing = false FeedLoaded(state, accountViewModel, navController) } is UserFeedState.Loading -> { @@ -63,6 +58,8 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode } } } + + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } 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 71fa2ee6c..5d54cbdc5 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 @@ -43,7 +43,6 @@ 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.painter.BitmapPainter import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner @@ -61,15 +60,14 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.actions.PostButton -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose @@ -78,7 +76,12 @@ import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel @Composable -fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, navController: NavController) { +fun ChannelScreen( + channelId: String?, + accountViewModel: AccountViewModel, + accountStateViewModel: AccountStateViewModel, + navController: NavController +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account @@ -223,11 +226,9 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont Column() { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - AsyncImageProxy( + RobohashAsyncImageProxy( + robot = channel.idHex, model = ResizeImage(channel.profilePicture(), 35.dp), - placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)), - fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)), - error = BitmapPainter(RoboHashCache.get(context, channel.idHex)), contentDescription = context.getString(R.string.profile_image), modifier = Modifier .width(35.dp) 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 c4736e760..e566bb055 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 @@ -1,245 +1,241 @@ -package com.vitorpamplona.amethyst.ui.screen.loggedIn - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextField -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.painter.BitmapPainter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrChatroomDataSource -import com.vitorpamplona.amethyst.ui.actions.PostButton -import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy -import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status -import com.vitorpamplona.amethyst.ui.components.ResizeImage -import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter -import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel - -@Composable -fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account - - if (account != null && userId != null) { - val newPost = remember { mutableStateOf(TextFieldValue("")) } - val replyTo = remember { mutableStateOf(null) } - - ChatroomFeedFilter.loadMessagesBetween(account, userId) - NostrChatroomDataSource.loadMessagesBetween(account, userId) - - val feedViewModel: NostrChatRoomFeedViewModel = viewModel() - val lifeCycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(userId) { - feedViewModel.refresh() - } - - DisposableEffect(userId) { - val observer = LifecycleEventObserver { source, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Private Message Start") - NostrChatroomDataSource.start() - feedViewModel.refresh() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Private Message Stop") - NostrChatroomDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - NostrChatroomDataSource.withUser?.let { - ChatroomHeader( - it, - accountViewModel = accountViewModel, - navController = navController - ) - } - - Column( - modifier = Modifier - .fillMaxHeight() - .padding(vertical = 0.dp) - .weight(1f, true) - ) { - ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/$userId") { - replyTo.value = it - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row(Modifier.padding(horizontal = 10.dp).animateContentSize(), verticalAlignment = Alignment.CenterVertically) { - val replyingNote = replyTo.value - if (replyingNote != null) { - Column(Modifier.weight(1f)) { - ChatroomMessageCompose( - baseNote = replyingNote, - null, - innerQuote = true, - accountViewModel = accountViewModel, - navController = navController, - onWantsToReply = { - replyTo.value = it - } - ) - } - - Column(Modifier.padding(end = 10.dp)) { - IconButton( - modifier = Modifier.size(30.dp), - onClick = { replyTo.value = null } - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Modifier.padding(end = 5.dp).size(30.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } - } - } - } - - // LAST ROW - Row( - modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = newPost.value, - onValueChange = { newPost.value = it }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - modifier = Modifier.weight(1f, true), - shape = RoundedCornerShape(25.dp), - placeholder = { - Text( - text = stringResource(id = R.string.reply_here), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - PostButton( - onPost = { - account.sendPrivateMeesage(newPost.value.text, userId, replyTo.value) - newPost.value = TextFieldValue("") - replyTo.value = null - feedViewModel.refresh() // Don't wait a full second before updating - }, - newPost.value.text.isNotBlank(), - modifier = Modifier.padding(end = 10.dp) - ) - }, - colors = TextFieldDefaults.textFieldColors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - } - } - } -} - -@Composable -fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { - val ctx = LocalContext.current.applicationContext - - Column( - modifier = Modifier.clickable( - onClick = { navController.navigate("User/${baseUser.pubkeyHex}") } - ) - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val authorState by baseUser.live().metadata.observeAsState() - val author = authorState?.user!! - - AsyncImageProxy( - model = ResizeImage(author.profilePicture(), 35.dp), - placeholder = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)), - fallback = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)), - error = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(35.dp) - .height(35.dp) - .clip(shape = CircleShape) - ) - - Column(modifier = Modifier.padding(start = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(baseUser) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status(baseUser) - } - } - } - } - - Divider( - modifier = Modifier.padding(start = 12.dp, end = 12.dp), - thickness = 0.25.dp - ) - } -} +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrChatroomDataSource +import com.vitorpamplona.amethyst.ui.actions.PostButton +import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status +import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy +import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter +import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel + +@Composable +fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account + + if (account != null && userId != null) { + val newPost = remember { mutableStateOf(TextFieldValue("")) } + val replyTo = remember { mutableStateOf(null) } + + ChatroomFeedFilter.loadMessagesBetween(account, userId) + NostrChatroomDataSource.loadMessagesBetween(account, userId) + + val feedViewModel: NostrChatRoomFeedViewModel = viewModel() + val lifeCycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(userId) { + feedViewModel.refresh() + } + + DisposableEffect(userId) { + val observer = LifecycleEventObserver { source, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Private Message Start") + NostrChatroomDataSource.start() + feedViewModel.refresh() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Private Message Stop") + NostrChatroomDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + NostrChatroomDataSource.withUser?.let { + ChatroomHeader( + it, + accountViewModel = accountViewModel, + navController = navController + ) + } + + Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + ) { + ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/$userId") { + replyTo.value = it + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row(Modifier.padding(horizontal = 10.dp).animateContentSize(), verticalAlignment = Alignment.CenterVertically) { + val replyingNote = replyTo.value + if (replyingNote != null) { + Column(Modifier.weight(1f)) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + navController = navController, + onWantsToReply = { + replyTo.value = it + } + ) + } + + Column(Modifier.padding(end = 10.dp)) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = { replyTo.value = null } + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier.padding(end = 5.dp).size(30.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } + } + + // LAST ROW + Row( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = newPost.value, + onValueChange = { newPost.value = it }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + modifier = Modifier.weight(1f, true), + shape = RoundedCornerShape(25.dp), + placeholder = { + Text( + text = stringResource(id = R.string.reply_here), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + PostButton( + onPost = { + account.sendPrivateMeesage(newPost.value.text, userId, replyTo.value) + newPost.value = TextFieldValue("") + replyTo.value = null + feedViewModel.refresh() // Don't wait a full second before updating + }, + newPost.value.text.isNotBlank(), + modifier = Modifier.padding(end = 10.dp) + ) + }, + colors = TextFieldDefaults.textFieldColors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) + } + } + } +} + +@Composable +fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { + val ctx = LocalContext.current.applicationContext + + Column( + modifier = Modifier.clickable( + onClick = { navController.navigate("User/${baseUser.pubkeyHex}") } + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val authorState by baseUser.live().metadata.observeAsState() + val author = authorState?.user!! + + RobohashAsyncImageProxy( + robot = author.pubkeyHex, + model = ResizeImage(author.profilePicture(), 35.dp), + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(35.dp) + .height(35.dp) + .clip(shape = CircleShape) + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UsernameDisplay(baseUser) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status(baseUser) + } + } + } + } + + Divider( + modifier = Modifier.padding(start = 12.dp, end = 12.dp), + thickness = 0.25.dp + ) + } +} 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 fb0809f79..ddf6af5ac 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 @@ -11,8 +11,6 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner @@ -20,51 +18,46 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.PagerState import com.google.accompanist.pager.pagerTabIndicatorOffset -import com.google.accompanist.pager.rememberPagerState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrHomeDataSource -import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.FeedView import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import kotlinx.coroutines.launch @OptIn(ExperimentalPagerApi::class) @Composable -fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - HomeNewThreadFeedFilter.account = account - HomeConversationsFeedFilter.account = account - - val feedViewModel: NostrHomeFeedViewModel = viewModel() - val feedViewModelReplies: NostrHomeRepliesFeedViewModel = viewModel() - - val pagerState = rememberPagerState() +fun HomeScreen( + accountViewModel: AccountViewModel, + navController: NavController, + homeFeedViewModel: NostrHomeFeedViewModel, + repliesFeedViewModel: NostrHomeRepliesFeedViewModel, + pagerState: PagerState, + scrollToTop: Boolean = false +) { val coroutineScope = rememberCoroutineScope() LaunchedEffect(accountViewModel) { NostrHomeDataSource.resetFilters() - feedViewModel.refresh() - feedViewModelReplies.refresh() + homeFeedViewModel.refresh() + repliesFeedViewModel.refresh() } val lifeCycleOwner = LocalLifecycleOwner.current DisposableEffect(accountViewModel) { - val observer = LifecycleEventObserver { source, event -> + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { NostrHomeDataSource.resetFilters() - feedViewModel.refresh() - feedViewModelReplies.refresh() + homeFeedViewModel.refresh() + repliesFeedViewModel.refresh() } } @@ -106,8 +99,8 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) } HorizontalPager(count = 2, state = pagerState) { when (pagerState.currentPage) { - 0 -> FeedView(feedViewModel, accountViewModel, navController, Route.Home.route + "Follows") - 1 -> FeedView(feedViewModelReplies, accountViewModel, navController, Route.Home.route + "FollowsReplies") + 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 fa698b275..a7f82a63f 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 @@ -7,9 +7,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.DrawerValue +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Scaffold import androidx.compose.material.rememberDrawerState +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -19,6 +23,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.buttons.NewChannelButton import com.vitorpamplona.amethyst.buttons.NewNoteButton +import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.AppTopBar @@ -28,31 +33,44 @@ import com.vitorpamplona.amethyst.ui.navigation.currentRoute import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { val navController = rememberNavController() val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) + val sheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + confirmValueChange = { it != ModalBottomSheetValue.HalfExpanded }, + skipHalfExpanded = true + ) - Scaffold( - modifier = Modifier - .background(MaterialTheme.colors.primaryVariant) - .statusBarsPadding(), - bottomBar = { - AppBottomBar(navController, accountViewModel) - }, - topBar = { - AppTopBar(navController, scaffoldState, accountViewModel) - }, - drawerContent = { - DrawerContent(navController, scaffoldState, accountViewModel, accountStateViewModel) - }, - floatingActionButton = { - FloatingButton(navController, accountStateViewModel) - }, - scaffoldState = scaffoldState + ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { + AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel) + } ) { - Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) { - AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage) + Scaffold( + modifier = Modifier + .background(MaterialTheme.colors.primaryVariant) + .statusBarsPadding(), + bottomBar = { + AppBottomBar(navController, accountViewModel) + }, + topBar = { + AppTopBar(navController, scaffoldState, accountViewModel) + }, + drawerContent = { + DrawerContent(navController, scaffoldState, sheetState, accountViewModel) + }, + floatingActionButton = { + FloatingButton(navController, accountStateViewModel) + }, + scaffoldState = scaffoldState + ) { + Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) { + AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage) + } } } } @@ -61,7 +79,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun fun FloatingButton(navController: NavHostController, accountViewModel: AccountStateViewModel) { val accountState by accountViewModel.accountContent.collectAsState() - if (currentRoute(navController) == Route.Home.route) { + if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is AccountState.LoggedInViewOnly -> { @@ -77,7 +95,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt } } - if (currentRoute(navController) == Route.Message.route) { + if (currentRoute(navController) == Route.Message.base) { Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> when (state) { is AccountState.LoggedInViewOnly -> { 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 82c7391fe..71c3c08ad 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 @@ -21,7 +21,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -51,7 +50,6 @@ 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.RoboHashCache import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -65,6 +63,8 @@ import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.components.ResizeImage +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter @@ -432,13 +432,17 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController: ) IconButton( - modifier = Modifier.size(30.dp).padding(start = 5.dp), + modifier = Modifier + .size(30.dp) + .padding(start = 5.dp), onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); } ) { Icon( imageVector = Icons.Default.ContentCopy, null, - modifier = Modifier.padding(end = 5.dp).size(15.dp), + modifier = Modifier + .padding(end = 5.dp) + .size(15.dp), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @@ -580,20 +584,18 @@ fun BadgeThumb( .height(size) ) { if (image == null) { - Image( - painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")), + RobohashAsyncImage( + robot = "authornotfound", contentDescription = stringResource(R.string.unknown_author), modifier = pictureModifier .fillMaxSize(1f) .background(MaterialTheme.colors.background) ) } else { - AsyncImage( + RobohashFallbackAsyncImage( + robot = note.idHex, model = image, contentDescription = stringResource(id = R.string.profile_image), - placeholder = BitmapPainter(RoboHashCache.get(ctx, note.idHex)), - fallback = BitmapPainter(RoboHashCache.get(ctx, note.idHex)), - error = BitmapPainter(RoboHashCache.get(ctx, note.idHex)), modifier = pictureModifier .fillMaxSize(1f) .clip(shape = CircleShape) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 444e46271..1707582bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource @@ -47,10 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache @@ -58,14 +55,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrGlobalDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource -import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.note.ChannelName import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.FeedView -import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest @@ -78,12 +75,12 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.channels.Channel as CoroutineChannel @Composable -fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - GlobalFeedFilter.account = account - val feedViewModel: NostrGlobalFeedViewModel = viewModel() +fun SearchScreen( + accountViewModel: AccountViewModel, + feedViewModel: FeedViewModel, + navController: NavController, + scrollToTop: Boolean = false +) { val lifeCycleOwner = LocalLifecycleOwner.current LaunchedEffect(accountViewModel) { @@ -114,7 +111,7 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle modifier = Modifier.padding(vertical = 0.dp) ) { SearchBar(accountViewModel, navController) - FeedView(feedViewModel, accountViewModel, navController, null) + FeedView(feedViewModel, accountViewModel, navController, null, ScrollStateKeys.GLOBAL_SCREEN, scrollToTop) } } } @@ -246,8 +243,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { index, item -> ChannelName( + channelIdHex = item.idHex, channelPicture = item.profilePicture(), - channelPicturePlaceholder = BitmapPainter(RoboHashCache.get(ctx, item.idHex)), channelTitle = { Text( "${item.info.name}", diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index ffa3c8ce4..d78673f7f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -1,4 +1,4 @@ -package com.vitorpamplona.amethyst.ui.screen +package com.vitorpamplona.amethyst.ui.screen.loggedOff import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -36,14 +36,18 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import java.util.* @OptIn(ExperimentalComposeUiApi::class) @Composable -fun LoginPage(accountViewModel: AccountStateViewModel) { +fun LoginPage( + accountViewModel: AccountStateViewModel, + isFirstLogin: Boolean +) { val key = remember { mutableStateOf(TextFieldValue("")) } var errorMessage by remember { mutableStateOf("") } - val acceptedTerms = remember { mutableStateOf(false) } + val acceptedTerms = remember { mutableStateOf(!isFirstLogin) } var termsAcceptanceIsRequired by remember { mutableStateOf("") } val uri = LocalUriHandler.current val context = LocalContext.current @@ -147,48 +151,50 @@ fun LoginPage(accountViewModel: AccountStateViewModel) { Spacer(modifier = Modifier.height(20.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it } - ) + if (isFirstLogin) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it } + ) - val regularText = - SpanStyle(color = MaterialTheme.colors.onBackground) + val regularText = + SpanStyle(color = MaterialTheme.colors.onBackground) - val clickableTextStyle = - SpanStyle(color = MaterialTheme.colors.primary) + val clickableTextStyle = + SpanStyle(color = MaterialTheme.colors.primary) - val annotatedTermsString = buildAnnotatedString { - withStyle(regularText) { - append(stringResource(R.string.i_accept_the)) - } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringResource(R.string.terms_of_use)) - } - } - - ClickableText( - text = annotatedTermsString - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset) - .firstOrNull() - ?.also { span -> - if (span.tag == "openTerms") { - runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } - } + val annotatedTermsString = buildAnnotatedString { + withStyle(regularText) { + append(stringResource(R.string.i_accept_the)) } - } - } - if (termsAcceptanceIsRequired.isNotBlank()) { - Text( - text = termsAcceptanceIsRequired, - color = MaterialTheme.colors.error, - style = MaterialTheme.typography.caption - ) + withStyle(clickableTextStyle) { + pushStringAnnotation("openTerms", "") + append(stringResource(R.string.terms_of_use)) + } + } + + ClickableText( + text = annotatedTermsString + ) { spanOffset -> + annotatedTermsString.getStringAnnotations(spanOffset, spanOffset) + .firstOrNull() + ?.also { span -> + if (span.tag == "openTerms") { + runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } + } + } + } + } + + if (termsAcceptanceIsRequired.isNotBlank()) { + Text( + text = termsAcceptanceIsRequired, + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption + ) + } } Spacer(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/res/drawable/manage_accounts.xml b/app/src/main/res/drawable/manage_accounts.xml new file mode 100644 index 000000000..734c70559 --- /dev/null +++ b/app/src/main/res/drawable/manage_accounts.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d61578c79..bdd90f7bb 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,17 +1,17 @@ Amethyst Amethyst debug - Richt naar de QR-Code - QR tonen - Profielafbeelding + Richt camera op de QR-Code + Toon QR + Profielfoto Scan QR - Laat evengoed zien - Bericht was gemarkeerd als ongepast door + Laat toch zien + Bericht gemarkeerd als ongepast door Bericht niet gevonden - Kanaalafbeelding + Groepsafbeelding Verwezen event niet gevonden - Kon het bericht niet ontcijferen - Groepafbeelding + Note versleuteld met encryptie + Kanaal-afbeelding Expliciete inhoud Spam Imitatie @@ -23,7 +23,7 @@ Kopieer auteur ID Kopieer note ID Verzenden - + Meld spam / scam Meld imitatie Rapporteer expliciete inhoud @@ -34,20 +34,20 @@ Geen Zap bedrag. Houdt ingedrukt om te veranderen Login met een privésleutel om Zaps te versturen Zaps - Bekijk telling + Aantal keer bekeken Boost boosted Quote Nieuw bedrag in sats Toevoegen - "reageren op " + "reageert op " " en " "in kanaal " Profielbanner " Volgend" " Volgers" Profiel - Beveiligingsfilters + Veiligheidsfilter Uitloggen Meer Lightning invoice @@ -57,8 +57,8 @@ Hartelijk bedankt! Bedrag in sats Verstuur sats - "Nooit vertalen van " - "Foutieve parsing preview voor %1$s : %2$s" + "Nooit vertalen vanuit " + "Error parsing preview voor %1$s : %2$s" "Voorbeeld kaartafbeelding voor %1$s" Nieuw kanaal Kanaalnaam @@ -71,58 +71,58 @@ Opslaan Maken Annuleren - Het uploaden van de afbeelding is mislukt - Relay addres + Uploaden afbeelding mislukt + Relay adres Berichten Errors - Beginfeed + Startpagina Privéberichten Publieke chats Globale feed Zoeken - AVoeg een relay toe + Voeg relay toe Naam Mijn naam Gebruikersnaam Mijn gebruikersnaam Over mij - Avatar URL + Profielfoto URL Banner URL Website URL LN Address LN URL (verouderd) - Afbeelding opgeslagen in de galerij + Afbeelding opgeslagen in galerij De afbeelding is niet opgeslagen Afbeelding uploaden Uploaden… - De gebruiker heeft geen Lightning Adress ingesteld om sats te ontvangen + Gebruiker heeft geen Lightning Adress ingesteld om sats te ontvangen "hier reageren.. " - Kopieert de Notitie ID naar klembord om te delen + Kopieert Notitie ID naar klembord om te delen Kopieer kanaal ID (Notitie) naar klembord Past de kanaal-metadata aan Lid worden Bekend Nieuw verzoek Geblokeerde gebruikers - Nieuwe draadjes + Nieuwe notities Gesprekken Notities Reacties "Volgend" "Rapporten" Meer opties - " Relays" + "Relays" Website Lightning Address - Kopieert het NSec ID (uw wachtwoord) naar het klembord voor back-up. + Kopieert het NSEC ID (uw wachtwoord) naar klembord voor back-up. Privésleutel kopiëren naar het klembord - Kopieert de publieke sleutel naar het klembord om te delen - Kopieer publieke sleutel (NPub) naar het klembord + Kopieert de publieke sleutel naar klembord om te delen + Kopieer publieke sleutel (NPUB) naar klembord Stuur een privébericht - Bewerkt de metagegevens van de gebruiker + Bewerkt de metadata van de gebruiker Volgen Deblokkeren - Kopieer gebruikers ID + Kopieer gebruiker ID Deblokkeer gebruiker "npub, hex, gebruikersnaam " Wissen @@ -137,12 +137,12 @@ Sleutel is vereist Inloggen Genereer een nieuwe sleutel - Feed laden + Feed laden… "Foutmelding bij het laden reacties: " Opnieuw proberen Feed is leeg. - Verversen - gemaakt + verversen + gecreëerd met beschrijving van en afbeelding heeft chatnaam veranderd naar @@ -154,7 +154,7 @@ "Kanaalinformatie veranderd naar" Publieke chat berichten ontvangen - Verwijderen + verwijderen sats Auto vertaald van @@ -177,7 +177,7 @@ Alle bekende als gelezen markeren Alle nieuwe als gelezen markeren Alles als gelezen markeren - Backup sleutels + Back-up sleutels ## Sleutel back-up en veiligheidstips \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index baff7e1ad..9874b9513 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Copy Author ID Copy Note ID Broadcast + Request Deletion Report Spam / Scam Report Impersonation @@ -178,7 +179,13 @@ Mark all New as read Mark all as read Backup Keys - " ## Key Backup and Safety Tips Your account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity. - Do **not** put your secret key in any website or software you do not trust. - Amethyst developers will **never** ask you for your secret key. - **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. " + + ## Key Backup and Safety Tips + \n\nYour account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity. + \n\n- Do **not** put your secret key in any website or software you do not trust. + \n- Amethyst developers will **never** ask you for your secret key. + \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. + Secret key (nsec) copied to clipboard Copy my secret key Authentication failed @@ -202,19 +209,24 @@ Follow Request Deletion Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored. - Github Gist w/ Proof Telegram Mastodon Post ID w/ Proof Twitter Status w/ Proof - https://gist.github.com/<user>/<gist> https://t.me/<proof post> https://<server>/<user>/<proof post> https://twitter.com/<user>/status/<proof post> - "<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s." Delete Don\'t show again + Add New Account + Accounts + Select Account + Add New Account + Active account + Has private key + Read only, no private key + Back