From 4e3b6d029904bb7410975c81fc1c12954a2a11fe Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 26 Aug 2024 14:42:55 -0400 Subject: [PATCH] - Adds a sync Signer to facilitate library - Separates Account actions from Account state in two objects - Changes Startup procedures to start with Account state and not the full account object - Moves scope for flows in Account from an Application-wide scope to ViewModel scope - Removes all LiveData objects from Account in favor of flows from the state object - Migrates settings saving logic to flows - Migrates PushNotification services to work without Account and only Account Settings. - Migrates the spam filter from LiveData to Flows - Adds Default lists for NIP-65 inbox and outbox relays - Adds Default lists for Search relays - Adds local backup for UserMetadata objects - Adds local backup for Mute lists - Adds local backup for NIP-65 relays - Adds local backup for DM Relays - Adds local backup for private home relays - Rewrites state flows initializers to avoid inconsistent startups --- .../amethyst/ImageUploadTesting.kt | 10 +- .../amethyst/ThreadAssemblerTest.kt | 5 +- .../notifications/PushMessageReceiver.kt | 6 +- .../com/vitorpamplona/amethyst/Amethyst.kt | 2 +- .../amethyst/LocalPreferences.kt | 583 ++++---- .../vitorpamplona/amethyst/ServiceManager.kt | 42 +- .../vitorpamplona/amethyst/model/Account.kt | 1258 ++++++++--------- .../amethyst/model/AccountSettings.kt | 522 +++++++ .../amethyst/model/AntiSpamFilter.kt | 29 +- .../com/vitorpamplona/amethyst/model/User.kt | 8 +- .../service/NostrAccountDataSource.kt | 8 +- .../service/NostrChatroomListDataSource.kt | 10 +- .../service/NostrDiscoveryDataSource.kt | 32 +- .../amethyst/service/NostrHomeDataSource.kt | 17 +- .../amethyst/service/NostrVideoDataSource.kt | 8 +- .../EventNotificationConsumer.kt | 129 +- .../service/notifications/RegisterAccounts.kt | 38 +- .../amethyst/ui/actions/EditPostView.kt | 4 +- .../amethyst/ui/actions/NewMediaModel.kt | 2 +- .../amethyst/ui/actions/NewMediaView.kt | 4 +- .../amethyst/ui/actions/NewPostView.kt | 6 +- .../ui/actions/NewUserMetadataViewModel.kt | 2 +- .../actions/relays/Kind3RelayListViewModel.kt | 4 +- .../actions/relays/LocalRelayListViewModel.kt | 4 +- .../ui/components/SensitivityWarning.kt | 4 +- .../amethyst/ui/dal/DiscoverChatFeedFilter.kt | 8 +- .../ui/dal/DiscoverCommunityFeedFilter.kt | 10 +- .../amethyst/ui/dal/DiscoverLiveFeedFilter.kt | 4 +- .../ui/dal/DiscoverMarketplaceFeedFilter.kt | 4 +- .../ui/dal/DiscoverNIP89FeedFilter.kt | 4 +- .../ui/dal/HomeConversationsFeedFilter.kt | 8 +- .../ui/dal/HomeNewThreadFeedFilter.kt | 8 +- .../NIP90ContentDiscoveryResponseFilter.kt | 4 +- .../amethyst/ui/dal/NotificationFeedFilter.kt | 8 +- .../ui/dal/UserProfileGalleryFeedFilter.kt | 6 +- .../amethyst/ui/dal/VideoFeedFilter.kt | 8 +- .../ui/navigation/AccountSwitchBottomSheet.kt | 10 +- .../amethyst/ui/navigation/AppTopBar.kt | 20 +- .../amethyst/ui/navigation/DrawerContent.kt | 13 +- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 3 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 10 +- .../amethyst/ui/note/PollNote.kt | 15 +- .../amethyst/ui/note/PollNoteViewModel.kt | 7 +- .../amethyst/ui/note/ReactionsRow.kt | 51 +- .../ui/note/UpdateReactionTypeDialog.kt | 17 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 41 +- .../amethyst/ui/note/ZapCustomDialog.kt | 4 +- .../note/elements/AddInboxRelayForDMCard.kt | 37 +- .../elements/AddInboxRelayForSearchCard.kt | 37 +- .../amethyst/ui/note/elements/DropDownMenu.kt | 9 +- .../ui/note/elements/ZapTheDevsCard.kt | 19 +- .../amethyst/ui/screen/AccountScreen.kt | 18 +- .../amethyst/ui/screen/AccountState.kt | 10 +- .../ui/screen/AccountStateViewModel.kt | 176 ++- .../ui/screen/loggedIn/AccountBackupDialog.kt | 4 +- .../ui/screen/loggedIn/AccountViewModel.kt | 84 +- .../ui/screen/loggedIn/HiddenUsersScreen.kt | 15 +- .../loggedIn/chatrooms/ChannelScreen.kt | 2 +- .../loggedIn/chatrooms/ChatroomScreen.kt | 10 +- .../loggedIn/notifications/CardFeedView.kt | 34 +- .../components/TranslatableRichTextViewer.kt | 26 +- .../filters/SinceAuthorPerRelayFilter.kt | 12 +- .../quartz/events/AdvertisedRelayListEvent.kt | 12 + .../events/ChatMessageRelayListEvent.kt | 7 + .../quartz/events/ContactListEvent.kt | 41 + .../com/vitorpamplona/quartz/events/Event.kt | 1 + .../quartz/events/GiftWrapEvent.kt | 11 +- .../quartz/events/LnZapPrivateEvent.kt | 8 + .../quartz/events/MetadataEvent.kt | 22 + .../quartz/events/SearchRelayListEvent.kt | 7 + .../quartz/signers/NostrSignerInternal.kt | 189 +-- .../quartz/signers/NostrSignerSync.kt | 241 ++++ 72 files changed, 2296 insertions(+), 1736 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerSync.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index f6a1be4e7..50b4c31d9 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -25,6 +25,7 @@ import android.graphics.Color import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.Nip96Retriever @@ -33,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.actions.ImageDownloader import com.vitorpamplona.quartz.crypto.KeyPair import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Ignore @@ -61,7 +65,11 @@ class ImageUploadTesting { val bytes = baos.toByteArray() val inputStream = bytes.inputStream() - val account = Account(KeyPair()) + val account = + Account( + AccountSettings(KeyPair()), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ) val result = Nip96Uploader(account) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt index cf43f6020..acfb6bedf 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadAssemblerTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.quartz.crypto.KeyPair @@ -30,7 +31,9 @@ import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.events.Event import junit.framework.TestCase import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.junit.Test @@ -133,7 +136,7 @@ class ThreadAssemblerTest { null, ) - val account = Account(KeyPair()) + val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob())) withContext(Dispatchers.Main) { val user = account.userProfile().live() } diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt index 6839761dd..5b2305765 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushMessageReceiver.kt @@ -33,10 +33,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver @@ -46,7 +43,7 @@ class PushMessageReceiver : MessagingReceiver() { } private val appContext = Amethyst.instance.applicationContext - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val scope = Amethyst.instance.applicationIOScope private val eventCache = LruCache(100) private val pushHandler = PushDistributorHandler @@ -111,7 +108,6 @@ class PushMessageReceiver : MessagingReceiver() { instance: String, ) { Log.d(TAG, "Registration failed for Instance: $instance") - scope.cancel() pushHandler.forceRemoveDistributor(context) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index aadbe2f0b..5260c4bec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -52,7 +52,7 @@ class Amethyst : Application() { val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Service Manager is only active when the activity is active. - val serviceManager = ServiceManager() + val serviceManager = ServiceManager(applicationIOScope) override fun onTerminate() { super.onTerminate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 000e19d32..6241b7303 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -26,7 +26,7 @@ import android.content.SharedPreferences import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultReactions import com.vitorpamplona.amethyst.model.DefaultZapAmounts import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS @@ -47,9 +47,10 @@ import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.LnZapEvent -import com.vitorpamplona.quartz.signers.ExternalSignerLauncher -import com.vitorpamplona.quartz.signers.NostrSignerExternal -import com.vitorpamplona.quartz.signers.NostrSignerInternal +import com.vitorpamplona.quartz.events.MetadataEvent +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.events.SearchRelayListEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -91,9 +92,13 @@ private object PrefKeys { const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" + const val LATEST_USER_METADATA = "latestUserMetadata" const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_DM_RELAY_LIST = "latestDMRelayList" const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList" + const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" + const val LATEST_MUTE_LIST = "latestMuteList" + const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later @@ -117,58 +122,68 @@ object LocalPreferences { private var currentAccount: String? = null private var savedAccounts: List? = null - private var cachedAccounts: MutableMap = mutableMapOf() + private var cachedAccounts: MutableMap = mutableMapOf() suspend fun currentAccount(): String? { if (currentAccount == null) { - currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + currentAccount = + withContext(Dispatchers.IO) { + encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + } } return currentAccount } - private fun updateCurrentAccount(npub: String?) { + private suspend fun updateCurrentAccount(npub: String?) { if (npub == null) { currentAccount = null - encryptedPreferences().edit().clear().apply() + withContext(Dispatchers.IO) { + encryptedPreferences().edit().clear().apply() + } } else if (currentAccount != npub) { currentAccount = npub - - encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() + withContext(Dispatchers.IO) { + encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() + } } } - private fun savedAccounts(): List { + private suspend fun savedAccounts(): List { if (savedAccounts == null) { - with(encryptedPreferences()) { - val newSystemOfAccounts = - getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { - Event.mapper.readValue>(it) - } - - if (!newSystemOfAccounts.isNullOrEmpty()) { - savedAccounts = newSystemOfAccounts - } else { - val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf() - - val migrated = - oldAccounts.map { npub -> - AccountInfo( - npub, - encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), - (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "") - .isNotBlank(), - ) + withContext(Dispatchers.IO) { + with(encryptedPreferences()) { + val newSystemOfAccounts = + getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { + Event.mapper.readValue>(it) } - savedAccounts = migrated + if (!newSystemOfAccounts.isNullOrEmpty()) { + savedAccounts = newSystemOfAccounts + } else { + val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf() - edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply() + val migrated = + oldAccounts.map { npub -> + AccountInfo( + npub, + encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), + (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "") + .isNotBlank(), + ) + } + + savedAccounts = migrated + + edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply() + } } } } return savedAccounts!! } + fun cachedAccounts() = savedAccounts + private suspend fun updateSavedAccounts(accounts: List) = withContext(Dispatchers.IO) { if (savedAccounts != accounts) { @@ -189,35 +204,33 @@ object LocalPreferences { updateSavedAccounts(accounts) } - private suspend fun setCurrentAccount(account: Account) = - withContext(Dispatchers.IO) { - val npub = account.userProfile().pubkeyNpub() - val accInfo = - AccountInfo( - npub, - account.isWriteable(), - account.signer is NostrSignerExternal, - ) - updateCurrentAccount(npub) - addAccount(accInfo) - } + private suspend fun setCurrentAccount(accountSettings: AccountSettings) { + val npub = accountSettings.keyPair.pubKey.toNpub() + val accInfo = + AccountInfo( + npub, + accountSettings.isWriteable(), + accountSettings.externalSignerPackageName != null, + ) + updateCurrentAccount(npub) + addAccount(accInfo) + } - suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) } + suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo.npub) /** Removes the account from the app level shared preferences */ private suspend fun removeAccount(accountInfo: AccountInfo) { - val accounts = savedAccounts().filter { it.npub != accountInfo.npub } - updateSavedAccounts(accounts) + updateSavedAccounts(savedAccounts().filter { it.npub != accountInfo.npub }) } /** Deletes the npub-specific shared preference file */ - private fun deleteUserPreferenceFile(npub: String) { - checkNotInMainThread() - - val prefsDir = File(prefsDirPath) - prefsDir.list()?.forEach { - if (it.contains(npub)) { - File(prefsDir, it).delete() + private suspend fun deleteUserPreferenceFile(npub: String) { + withContext(Dispatchers.IO) { + val prefsDir = File(prefsDirPath) + prefsDir.list()?.forEach { + if (it.contains(npub)) { + File(prefsDir, it).delete() + } } } } @@ -246,8 +259,7 @@ object LocalPreferences { suspend fun updatePrefsForLogout(accountInfo: AccountInfo) { Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout") withContext(Dispatchers.IO) { - val userPrefs = encryptedPreferences(accountInfo.npub) - userPrefs.edit().clear().commit() + encryptedPreferences(accountInfo.npub).edit().clear().commit() removeAccount(accountInfo) deleteUserPreferenceFile(accountInfo.npub) @@ -259,99 +271,134 @@ object LocalPreferences { } } - suspend fun updatePrefsForLogin(account: Account) { - setCurrentAccount(account) - saveToEncryptedStorage(account) + suspend fun updatePrefsForLogin(accountSettings: AccountSettings) { + setCurrentAccount(accountSettings) + saveToEncryptedStorage(accountSettings) } - fun allSavedAccounts(): List = savedAccounts() + suspend fun allSavedAccounts(): List = savedAccounts() - suspend fun saveToEncryptedStorage(account: Account) { + suspend fun saveToEncryptedStorage(settings: AccountSettings) { Log.d("LocalPreferences", "Saving to encrypted storage") withContext(Dispatchers.IO) { - checkNotInMainThread() - - val prefs = encryptedPreferences(account.userProfile().pubkeyNpub()) + val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub()) prefs .edit() .apply { - putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal) - if (account.signer is NostrSignerExternal) { + putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null) + if (settings.externalSignerPackageName != null) { remove(PrefKeys.NOSTR_PRIVKEY) - putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName) + putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName) } else { - account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } + remove(PrefKeys.SIGNER_PACKAGE_NAME) + settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } } - account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays)) - putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) - putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers) + settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } + putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays)) + putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom) + putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers) putString( PrefKeys.LANGUAGE_PREFS, - Event.mapper.writeValueAsString(account.languagePreferences), + Event.mapper.writeValueAsString(settings.languagePreferences), ) - putString(PrefKeys.TRANSLATE_TO, account.translateTo) - putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices)) + putString(PrefKeys.TRANSLATE_TO, settings.translateTo) + putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value)) putString( PrefKeys.REACTION_CHOICES, - Event.mapper.writeValueAsString(account.reactionChoices), + Event.mapper.writeValueAsString(settings.reactionChoices.value), ) - putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.value.name) + putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name) putString( PrefKeys.DEFAULT_FILE_SERVER, - Event.mapper.writeValueAsString(account.defaultFileServer), + Event.mapper.writeValueAsString(settings.defaultFileServer), ) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value) + putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value) + putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value) putString( PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, - account.defaultNotificationFollowList.value, + settings.defaultNotificationFollowList.value, ) putString( PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, - account.defaultDiscoveryFollowList.value, + settings.defaultDiscoveryFollowList.value, ) putString( PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, - Event.mapper.writeValueAsString(account.zapPaymentRequest), + Event.mapper.writeValueAsString(settings.zapPaymentRequest), ) - if (account.backupContactList != null) { + if (settings.backupContactList != null) { putString( PrefKeys.LATEST_CONTACT_LIST, - Event.mapper.writeValueAsString(account.backupContactList), + Event.mapper.writeValueAsString(settings.backupContactList), ) } else { remove(PrefKeys.LATEST_CONTACT_LIST) } - if (account.backupDMRelayList != null) { + if (settings.backupUserMetadata != null) { + putString( + PrefKeys.LATEST_USER_METADATA, + Event.mapper.writeValueAsString(settings.backupUserMetadata), + ) + } else { + remove(PrefKeys.LATEST_USER_METADATA) + } + + if (settings.backupDMRelayList != null) { putString( PrefKeys.LATEST_DM_RELAY_LIST, - Event.mapper.writeValueAsString(account.backupDMRelayList), + Event.mapper.writeValueAsString(settings.backupDMRelayList), ) } else { remove(PrefKeys.LATEST_DM_RELAY_LIST) } - if (account.backupNIP65RelayList != null) { + if (settings.backupNIP65RelayList != null) { putString( PrefKeys.LATEST_NIP65_RELAY_LIST, - Event.mapper.writeValueAsString(account.backupNIP65RelayList), + Event.mapper.writeValueAsString(settings.backupNIP65RelayList), ) } else { remove(PrefKeys.LATEST_NIP65_RELAY_LIST) } - putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) - putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, account.hideNIP17WarningDialog) - putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog) - putBoolean(PrefKeys.USE_PROXY, account.proxy != null) - putInt(PrefKeys.PROXY_PORT, account.proxyPort) - putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports) - putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers) + if (settings.backupSearchRelayList != null) { + putString( + PrefKeys.LATEST_SEARCH_RELAY_LIST, + Event.mapper.writeValueAsString(settings.backupSearchRelayList), + ) + } else { + remove(PrefKeys.LATEST_SEARCH_RELAY_LIST) + } + + if (settings.backupMuteList != null) { + putString( + PrefKeys.LATEST_MUTE_LIST, + Event.mapper.writeValueAsString(settings.backupMuteList), + ) + } else { + remove(PrefKeys.LATEST_MUTE_LIST) + } + + if (settings.backupPrivateHomeRelayList != null) { + putString( + PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, + Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList), + ) + } else { + remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) + } + + putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) + putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) + putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) + putBoolean(PrefKeys.USE_PROXY, settings.proxy != null) + putInt(PrefKeys.PROXY_PORT, settings.proxyPort) + putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports) + putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers) val regularMap = - account.lastReadPerRoute.value.mapValues { + settings.lastReadPerRoute.value.mapValues { it.value.value } @@ -359,23 +406,23 @@ object LocalPreferences { PrefKeys.LAST_READ_PER_ROUTE, Event.mapper.writeValueAsString(regularMap), ) - putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion) + putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) - if (account.showSensitiveContent.value == null) { + if (settings.showSensitiveContent.value == null) { remove(PrefKeys.SHOW_SENSITIVE_CONTENT) } else { - putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent.value!!) + putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!) } putString( PrefKeys.PENDING_ATTESTATIONS, - Event.mapper.writeValueAsString(account.pendingAttestations.value), + Event.mapper.writeValueAsString(settings.pendingAttestations.value), ) }.apply() } } - suspend fun loadCurrentAccountFromEncryptedStorage(): Account? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) } + suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) } suspend fun saveSharedSettings( sharedSettings: Settings, @@ -408,39 +455,39 @@ object LocalPreferences { val mutex = Mutex() - suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = - withContext(Dispatchers.IO) { + suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? { + // if already loaded, return right away + if (cachedAccounts.containsKey(npub)) { + return cachedAccounts[npub] + } + + return withContext(Dispatchers.IO) { mutex.withLock { if (cachedAccounts.containsKey(npub)) { return@withContext cachedAccounts.get(npub) } - val account = innerLoadCurrentAccountFromEncryptedStorage(npub) - account?.registerObservers() + val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub) - cachedAccounts.put(npub, account) + cachedAccounts.put(npub, accountSettings) - return@withContext account + return@withContext accountSettings } } + } - suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? { - Log.d("LocalPreferences", "Load account from file") + private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { + Log.d("LocalPreferences", "Load account from file $npub") return withContext(Dispatchers.IO) { checkNotInMainThread() return@withContext with(encryptedPreferences(npub)) { + val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null - val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false) - val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null) - - val localRelays = - getString(PrefKeys.RELAYS, "[]")?.let { - println("LocalRelays: $it") - Event.mapper.readValue?>(it) - } - ?: setOf() + val externalSignerPackageName = + getString(PrefKeys.SIGNER_PACKAGE_NAME, null) + ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() @@ -454,149 +501,27 @@ object LocalPreferences { val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val zapAmountChoices = - getString(PrefKeys.ZAP_AMOUNTS, "[]") - ?.let { Event.mapper.readValue?>(it) } - ?.ifEmpty { DefaultZapAmounts } - ?: DefaultZapAmounts - - val reactionChoices = - getString(PrefKeys.REACTION_CHOICES, "[]") - ?.let { Event.mapper.readValue?>(it) } - ?.ifEmpty { DefaultReactions } - ?: DefaultReactions - val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName -> - LnZapEvent.ZapType.values().firstOrNull { it.name == serverName } - } - ?: LnZapEvent.ZapType.PUBLIC + LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName } + } ?: LnZapEvent.ZapType.PUBLIC - val defaultFileServer = - try { - getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> - Event.mapper.readValue(serverName) - } - ?: Nip96MediaServers.DEFAULT[0] - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("LocalPreferences", "Failed to decode saved File Server", e) - e.printStackTrace() - Nip96MediaServers.DEFAULT[0] - } + val localRelays = parseOrNull>(PrefKeys.RELAYS) ?: emptySet() + val reactionChoices = parseOrNull>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions + val zapAmountChoices = parseOrNull>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts - val zapPaymentRequestServer = - try { - getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { - Event.mapper.readValue(it) - } - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w( - "LocalPreferences", - "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", - e, - ) - e.printStackTrace() - null - } + val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0] + val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) + val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() + val languagePreferences = parseOrNull>(PrefKeys.LANGUAGE_PREFS) ?: mapOf() - val latestContactList = - try { - getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { - if (it != "null") { - println("Decoding Contact List: $it") - Event.fromJson(it) as ContactListEvent? - } else { - null - } - } - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w( - "LocalPreferences", - "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", - e, - ) - null - } - - val latestDmRelayList = - try { - getString(PrefKeys.LATEST_DM_RELAY_LIST, null)?.let { - if (it != "null") { - println("Decoding DM Relay List: $it") - Event.fromJson(it) as ChatMessageRelayListEvent? - } else { - null - } - } - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w( - "LocalPreferences", - "Error Decoding DM Relay List ${getString(PrefKeys.LATEST_DM_RELAY_LIST, null)}", - e, - ) - null - } - - val latestNip65RelayList = - try { - getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)?.let { - if (it != "null") { - println("Decoding NIP65 Relay List: $it") - Event.fromJson(it) as AdvertisedRelayListEvent? - } else { - null - } - } - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w( - "LocalPreferences", - "Error Decoding NIP65 Relay List ${getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)}", - e, - ) - null - } - - val pendingAttestations = - try { - getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let { - println("Decoding Attestation List: " + it) - if (it != null) { - Event.mapper.readValue>(it) - } else { - null - } - } - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w( - "LocalPreferences", - "Error Decoding Contact List ${getString(PrefKeys.PENDING_ATTESTATIONS, null)}", - e, - ) - null - } - - val languagePreferences = - try { - getString(PrefKeys.LANGUAGE_PREFS, null)?.let { - Event.mapper.readValue?>(it) - } - ?: mapOf() - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w( - "LocalPreferences", - "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", - e, - ) - e.printStackTrace() - mapOf() - } + val latestUserMetadata = parseEventOrNull(PrefKeys.LATEST_USER_METADATA) + val latestContactList = parseEventOrNull(PrefKeys.LATEST_CONTACT_LIST) + val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) + val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) + val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) + val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) + val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) @@ -615,82 +540,84 @@ object LocalPreferences { val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) val lastReadPerRoute = - try { - getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let { - Event.mapper.readValue?>(it)?.mapValues { - MutableStateFlow(it.value) - } - } ?: mapOf() - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w( - "LocalPreferences", - "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", - e, - ) - e.printStackTrace() - mapOf() - } + parseOrNull>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { + MutableStateFlow(it.value) + } ?: mapOf() val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) - val signer = - if (loginWithExternalSigner) { - val packageName = - getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner" - NostrSignerExternal( - pubKey, - ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName), - ) - } else { - NostrSignerInternal(keyPair) - } - val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() - val account = - Account( - keyPair = keyPair, - signer = signer, - localRelays = localRelays, - localRelayServers = localRelayServers, - dontTranslateFrom = dontTranslateFrom, - languagePreferences = languagePreferences, - translateTo = translateTo, - zapAmountChoices = zapAmountChoices, - reactionChoices = reactionChoices, - defaultZapType = MutableStateFlow(defaultZapType), - defaultFileServer = defaultFileServer, - defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), - defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), - defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), - defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = zapPaymentRequestServer, - hideDeleteRequestDialog = hideDeleteRequestDialog, - hideBlockAlertDialog = hideBlockAlertDialog, - hideNIP17WarningDialog = hideNIP17WarningDialog, - backupContactList = latestContactList, - backupNIP65RelayList = latestNip65RelayList, - backupDMRelayList = latestDmRelayList, - proxy = proxy, - proxyPort = proxyPort, - showSensitiveContent = MutableStateFlow(showSensitiveContent), - warnAboutPostsWithReports = warnAboutReports, - filterSpamFromStrangers = filterSpam, - lastReadPerRoute = MutableStateFlow(lastReadPerRoute), - hasDonatedInVersion = hasDonatedInVersion, - pendingAttestations = MutableStateFlow(pendingAttestations ?: emptyMap()), - ) - - // Loads from DB - account.userProfile() - - withContext(Dispatchers.Main) { - // Loads Live Objects - account.userProfile().live() - } - - return@with account + return@with AccountSettings( + keyPair = keyPair, + externalSignerPackageName = externalSignerPackageName, + localRelays = localRelays, + localRelayServers = localRelayServers, + dontTranslateFrom = dontTranslateFrom, + languagePreferences = languagePreferences, + translateTo = translateTo, + zapAmountChoices = MutableStateFlow(zapAmountChoices), + reactionChoices = MutableStateFlow(reactionChoices), + defaultZapType = MutableStateFlow(defaultZapType), + defaultFileServer = defaultFileServer, + defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), + defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), + defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), + defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), + zapPaymentRequest = zapPaymentRequestServer, + hideDeleteRequestDialog = hideDeleteRequestDialog, + hideBlockAlertDialog = hideBlockAlertDialog, + hideNIP17WarningDialog = hideNIP17WarningDialog, + backupUserMetadata = latestUserMetadata, + backupContactList = latestContactList, + backupNIP65RelayList = latestNip65RelayList, + backupDMRelayList = latestDmRelayList, + backupSearchRelayList = latestSearchRelayList, + backupPrivateHomeRelayList = latestPrivateHomeRelayList, + backupMuteList = latestMuteList, + proxy = proxy, + proxyPort = proxyPort, + showSensitiveContent = MutableStateFlow(showSensitiveContent), + warnAboutPostsWithReports = warnAboutReports, + filterSpamFromStrangers = filterSpam, + lastReadPerRoute = MutableStateFlow(lastReadPerRoute), + hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), + pendingAttestations = MutableStateFlow(pendingAttestations), + ) } } } + + private inline fun SharedPreferences.parseOrNull(key: String): T? { + val value = getString(key, null) + if (value.isNullOrEmpty() || value == "null") { + return null + } + return try { + if (T::class.java.isInstance(Event::class.java)) { + Event.fromJson(value) as T? + } else { + Event.mapper.readValue(value) + } + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e) + e.printStackTrace() + null + } + } + + private inline fun SharedPreferences.parseEventOrNull(key: String): T? { + val value = getString(key, null) + if (value.isNullOrEmpty() || value == "null") { + return null + } + return try { + Event.fromJson(value) as T? + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e) + e.printStackTrace() + null + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 965568656..cbcd93430 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -28,6 +28,7 @@ import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder import coil.size.Precision +import coil.util.DebugLogger import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.Base64Fetcher @@ -57,18 +58,19 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @Stable -class ServiceManager { +class ServiceManager( + val scope: CoroutineScope, +) { private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications private var account: Account? = null - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var collectorJob: Job? = null private fun start(account: Account) { @@ -86,9 +88,9 @@ class ServiceManager { val myAccount = account // Resets Proxy Use - HttpClientManager.setDefaultProxy(account?.proxy) + HttpClientManager.setDefaultProxy(account?.settings?.proxy) HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}") - LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true + LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true Coil.setImageLoader { Amethyst.instance .imageLoaderBuilder() @@ -100,8 +102,11 @@ class ServiceManager { } add(SvgDecoder.Factory()) add(Base64Fetcher.Factory) - } // .logger(DebugLogger()) - .okHttpClient { HttpClientManager.getHttpClient() } + }.apply { + if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") { + this.logger(DebugLogger()) + } + }.okHttpClient { HttpClientManager.getHttpClient() } .precision(Precision.INEXACT) .respectCacheHeaders(false) .build() @@ -126,20 +131,23 @@ class ServiceManager { // start services NostrAccountDataSource.account = myAccount - NostrAccountDataSource.otherAccounts = - LocalPreferences.allSavedAccounts().mapNotNull { - try { - it.npub.bechToBytes().toHexKey() - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - } NostrHomeDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount NostrVideoDataSource.account = myAccount NostrDiscoveryDataSource.account = myAccount + NostrAccountDataSource.otherAccounts = + runBlocking { + LocalPreferences.allSavedAccounts().mapNotNull { + try { + it.npub.bechToBytes().toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + } + // Notification Elements NostrHomeDataSource.start() NostrAccountDataSource.start() @@ -190,7 +198,7 @@ class ServiceManager { LocalCache.cleanObservers() } - fun trimMemory() { + suspend fun trimMemory() { LocalCache.cleanObservers() val accounts = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index bd167df80..fc06db1a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -20,19 +20,15 @@ */ package com.vitorpamplona.amethyst.model -import android.content.res.Resources import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.switchMap -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.FileHeader -import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.ammolite.relays.BundledUpdate @@ -46,10 +42,8 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.Nip47WalletConnect import com.vitorpamplona.quartz.encoders.RelayUrlFormatter import com.vitorpamplona.quartz.encoders.hexToByteArray -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent @@ -106,7 +100,6 @@ import com.vitorpamplona.quartz.events.TorrentCommentEvent import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.signers.NostrSigner -import com.vitorpamplona.quartz.signers.NostrSignerExternal import com.vitorpamplona.quartz.signers.NostrSignerInternal import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope @@ -122,91 +115,25 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.math.BigDecimal -import java.net.Proxy -import java.util.Locale import java.util.UUID import kotlin.coroutines.resume -val DefaultChannels = - setOf( - // Anigma's Nostr - "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", - // Amethyst's Group - "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", - ) - -val DefaultReactions = - listOf( - "\uD83D\uDE80", - "\uD83E\uDEC2", - "\uD83D\uDC40", - "\uD83D\uDE02", - "\uD83C\uDF89", - "\uD83E\uDD14", - "\uD83D\uDE31", - ) - -val DefaultZapAmounts = listOf(100L, 500L, 1000L) - -fun getLanguagesSpokenByUser(): Set { - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - val codedList = mutableSetOf() - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { codedList.add(it.language) } - } - return codedList -} - -val GLOBAL_FOLLOWS = - " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val KIND3_FOLLOWS = - " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same -// name. - @OptIn(DelicateCoroutinesApi::class) @Stable class Account( - val keyPair: KeyPair, - val signer: NostrSigner = NostrSignerInternal(keyPair), - var localRelays: Set = Constants.defaultRelays.toSet(), - var localRelayServers: Set = setOf(), - var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var languagePreferences: Map = mapOf(), - var translateTo: String = Locale.getDefault().language, - var zapAmountChoices: List = DefaultZapAmounts, - var reactionChoices: List = DefaultReactions, - var defaultZapType: MutableStateFlow = MutableStateFlow(LnZapEvent.ZapType.PUBLIC), - var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], - var defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), - var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null, - var hideDeleteRequestDialog: Boolean = false, - var hideBlockAlertDialog: Boolean = false, - var hideNIP17WarningDialog: Boolean = false, - var backupContactList: ContactListEvent? = null, - var backupDMRelayList: ChatMessageRelayListEvent? = null, - var backupNIP65RelayList: AdvertisedRelayListEvent? = null, - var proxy: Proxy? = null, - var proxyPort: Int = 9050, - var showSensitiveContent: MutableStateFlow = MutableStateFlow(null), - var warnAboutPostsWithReports: Boolean = true, - var filterSpamFromStrangers: Boolean = true, - var lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), - var hasDonatedInVersion: Set = setOf(), - var pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), - val scope: CoroutineScope = Amethyst.instance.applicationIOScope, + val settings: AccountSettings = AccountSettings(KeyPair()), + val signer: NostrSigner = settings.createSigner(), + val scope: CoroutineScope, ) { var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) @@ -218,10 +145,6 @@ class Account( var transientPaymentRequestDismissals: Set = emptySet() val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) - // Observers line up here. - val live: AccountLiveData = AccountLiveData(this) - val saveable: AccountLiveData = AccountLiveData(this) - @Immutable class LiveFollowLists( val users: Set = emptySet(), @@ -245,216 +168,295 @@ class Account( userProfile().flow().relays.stateFlow, ) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile -> checkNotInMainThread() - - val localRelays = convertLocalRelays() - val baseRelaySet = activeRelays() ?: localRelays - val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - val searchRelaySet = ((searchRelayList.note.event as? SearchRelayListEvent)?.relays() ?: Constants.defaultSearchRelaySet).map { RelayUrlFormatter.normalize(it) }.toSet() - val nip65RelaySet = - (nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays()?.map { - AdvertisedRelayListEvent.AdvertisedRelayInfo( - RelayUrlFormatter.normalize(it.relayUrl), - it.type, - ) - } - val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - - // ------ - // DMs - // ------ - - var mappedRelaySet = - baseRelaySet.map { - if (newDMRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + FeedType.PRIVATE_DMS) - } else { - it - } - } - - newDMRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // ------ - // SEARCH - // ------ - - mappedRelaySet = - mappedRelaySet.map { - if (searchRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, it.write || false, it.feedTypes + FeedType.SEARCH) - } else { - it - } - } - - searchRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - false, - setOf( - FeedType.SEARCH, - ), - ) - } - } - - // -------------- - // PRIVATE OUTBOX - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (privateOutboxRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - privateOutboxRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // Local Storage - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (localRelayServers.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - localRelayServers.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // NIP-65 Public Inbox/Outbox - // -------------- - - mappedRelaySet = - mappedRelaySet.map { relay -> - val nip65setup = nip65RelaySet?.firstOrNull { relay.url == it.relayUrl } - if (nip65setup != null) { - val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ - - RelaySetupInfo( - relay.url, - true, - relay.write || write, - relay.feedTypes + - setOf( - FeedType.FOLLOWS, - FeedType.GLOBAL, - FeedType.PUBLIC_CHATS, - ), - ) - } else { - relay - } - } - - nip65RelaySet?.forEach { newNip65Setup -> - if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) { - val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ - - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newNip65Setup.relayUrl, - true, - write, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - ), - ) - } - } - - emit(mappedRelaySet.toTypedArray()) + emit( + normalizeAndCombineRelayListsWithFallbacks( + kind3RelayList = kind3Relays(), + newDMRelayEvent = dmRelayList.note.event as? ChatMessageRelayListEvent, + searchRelayEvent = searchRelayList.note.event as? SearchRelayListEvent, + privateOutboxRelayEvent = privateOutBox.note.event as? PrivateOutboxRelayListEvent, + nip65RelayEvent = nip65RelayList.note.event as? AdvertisedRelayListEvent, + ).toTypedArray(), + ) } - val connectToRelays = connectToRelaysFlow.stateIn(scope, SharingStarted.Eagerly, activeRelays() ?: convertLocalRelays()) + private fun normalizeAndCombineRelayListsWithFallbacks( + kind3RelayList: Array? = null, + newDMRelayEvent: ChatMessageRelayListEvent? = null, + searchRelayEvent: SearchRelayListEvent? = null, + privateOutboxRelayEvent: PrivateOutboxRelayListEvent? = null, + nip65RelayEvent: AdvertisedRelayListEvent? = null, + localRelayList: Set? = null, + ) = normalizeAndCombineRelayLists( + baseRelaySet = kind3RelayList ?: convertLocalRelays(), + newDMRelayEvent = newDMRelayEvent ?: settings.backupDMRelayList, + searchRelayEvent = searchRelayEvent ?: settings.backupSearchRelayList, + privateOutboxRelayEvent = privateOutboxRelayEvent ?: settings.backupPrivateHomeRelayList, + nip65RelayEvent = nip65RelayEvent ?: settings.backupNIP65RelayList, + localRelayList = localRelayList ?: settings.localRelayServers, + ) + + private fun normalizeAndCombineRelayLists( + baseRelaySet: Array, + newDMRelayEvent: ChatMessageRelayListEvent?, + searchRelayEvent: SearchRelayListEvent?, + privateOutboxRelayEvent: PrivateOutboxRelayListEvent?, + nip65RelayEvent: AdvertisedRelayListEvent?, + localRelayList: Set, + ): List { + val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() + val searchRelaySet = (searchRelayEvent?.relays() ?: Constants.defaultSearchRelaySet).map { RelayUrlFormatter.normalize(it) }.toSet() + val nip65RelaySet = + nip65RelayEvent?.relays()?.map { + AdvertisedRelayListEvent.AdvertisedRelayInfo( + RelayUrlFormatter.normalize(it.relayUrl), + it.type, + ) + } + val privateOutboxRelaySet = privateOutboxRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() + val localRelaySet = localRelayList.map { RelayUrlFormatter.normalize(it) }.toSet() + + return combineRelayLists( + baseRelaySet = baseRelaySet, + newDMRelaySet = newDMRelaySet, + searchRelaySet = searchRelaySet, + privateOutboxRelaySet = privateOutboxRelaySet, + nip65RelaySet = nip65RelaySet, + localRelaySet = localRelaySet, + ) + } + + private fun combineRelayLists( + baseRelaySet: Array, + newDMRelaySet: Set, + searchRelaySet: Set, + privateOutboxRelaySet: Set, + nip65RelaySet: List?, + localRelaySet: Set, + ): List { + // ------ + // DMs + // ------ + var mappedRelaySet = + baseRelaySet.map { + if (newDMRelaySet.contains(it.url)) { + RelaySetupInfo(it.url, true, true, it.feedTypes + FeedType.PRIVATE_DMS) + } else { + it + } + } + + newDMRelaySet.forEach { newUrl -> + if (mappedRelaySet.none { it.url == newUrl }) { + mappedRelaySet = mappedRelaySet + + RelaySetupInfo( + newUrl, + true, + true, + setOf( + FeedType.PRIVATE_DMS, + ), + ) + } + } + + // ------ + // SEARCH + // ------ + + mappedRelaySet = + mappedRelaySet.map { + if (searchRelaySet.contains(it.url)) { + RelaySetupInfo(it.url, true, it.write || false, it.feedTypes + FeedType.SEARCH) + } else { + it + } + } + + searchRelaySet.forEach { newUrl -> + if (mappedRelaySet.none { it.url == newUrl }) { + mappedRelaySet = mappedRelaySet + + RelaySetupInfo( + newUrl, + true, + false, + setOf( + FeedType.SEARCH, + ), + ) + } + } + + // -------------- + // PRIVATE OUTBOX + // -------------- + + mappedRelaySet = + mappedRelaySet.map { + if (privateOutboxRelaySet.contains(it.url)) { + RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) + } else { + it + } + } + + privateOutboxRelaySet.forEach { newUrl -> + if (mappedRelaySet.none { it.url == newUrl }) { + mappedRelaySet = mappedRelaySet + + RelaySetupInfo( + newUrl, + true, + true, + setOf( + FeedType.FOLLOWS, + FeedType.PUBLIC_CHATS, + FeedType.GLOBAL, + FeedType.PRIVATE_DMS, + ), + ) + } + } + + // -------------- + // Local Storage + // -------------- + + mappedRelaySet = + mappedRelaySet.map { + if (localRelaySet.contains(it.url)) { + RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) + } else { + it + } + } + + localRelaySet.forEach { newUrl -> + if (mappedRelaySet.none { it.url == newUrl }) { + mappedRelaySet = mappedRelaySet + + RelaySetupInfo( + newUrl, + true, + true, + setOf( + FeedType.FOLLOWS, + FeedType.PUBLIC_CHATS, + FeedType.GLOBAL, + FeedType.PRIVATE_DMS, + ), + ) + } + } + + // -------------- + // NIP-65 Public Inbox/Outbox + // -------------- + + mappedRelaySet = + mappedRelaySet.map { relay -> + val nip65setup = nip65RelaySet?.firstOrNull { relay.url == it.relayUrl } + if (nip65setup != null) { + val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ + + RelaySetupInfo( + relay.url, + true, + relay.write || write, + relay.feedTypes + + setOf( + FeedType.FOLLOWS, + FeedType.GLOBAL, + FeedType.PUBLIC_CHATS, + ), + ) + } else { + relay + } + } + + nip65RelaySet?.forEach { newNip65Setup -> + if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) { + val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ + + mappedRelaySet = mappedRelaySet + + RelaySetupInfo( + newNip65Setup.relayUrl, + true, + write, + setOf( + FeedType.FOLLOWS, + FeedType.PUBLIC_CHATS, + ), + ) + } + } + return mappedRelaySet + } + + val connectToRelays = + connectToRelaysFlow + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + normalizeAndCombineRelayListsWithFallbacks( + kind3Relays(), + getDMRelayList(), + getSearchRelayList(), + getPrivateOutboxRelayList(), + getNIP65RelayList(), + ).toTypedArray(), + ) + + fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowLists { + // makes sure the output include only valid p tags + val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() + + return LiveFollowLists( + verifiedFollowingUsers, + verifiedFollowingUsers + signer.pubKey, + latestContactList + ?.unverifiedFollowTagSet() + ?.map { it.lowercase() } + ?.toSet() ?: emptySet(), + latestContactList + ?.unverifiedFollowGeohashSet() + ?.toSet() ?: emptySet(), + latestContactList + ?.verifiedFollowAddressSet() + ?.toSet() ?: emptySet(), + ) + } @OptIn(ExperimentalCoroutinesApi::class) val liveKind3FollowsFlow: Flow = userProfile().flow().follows.stateFlow.transformLatest { checkNotInMainThread() - - // makes sure the output include only valid p tags - val verifiedFollowingUsers = it.user.latestContactList?.verifiedFollowKeySet() ?: emptySet() - - emit( - LiveFollowLists( - verifiedFollowingUsers, - verifiedFollowingUsers + signer.pubKey, - it.user.latestContactList - ?.unverifiedFollowTagSet() - ?.map { it.lowercase() } - ?.toSet() ?: emptySet(), - it.user.latestContactList - ?.unverifiedFollowGeohashSet() - ?.toSet() ?: emptySet(), - it.user.latestContactList - ?.verifiedFollowAddressSet() - ?.toSet() ?: emptySet(), - ), - ) + emit(buildFollowLists(it.user.latestContactList)) } - val liveKind3Follows = liveKind3FollowsFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(signer.pubKey))) + val liveKind3Follows = + liveKind3FollowsFlow + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + buildFollowLists(userProfile().latestContactList ?: settings.backupContactList), + ) @OptIn(ExperimentalCoroutinesApi::class) private val liveHomeList: Flow = - defaultHomeFollowList.flatMapLatest { listName -> + settings.defaultHomeFollowList.flatMapLatest { listName -> loadPeopleListFlowFromListName(listName) } + fun peopleListFromListNameStarter(listName: String): ListNameNotePair = + if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { + val note = LocalCache.checkGetOrCreateAddressableNote(listName) + val noteEvent = note?.event as? GeneralListEvent + ListNameNotePair(listName, noteEvent) + } else { + ListNameNotePair(listName, null) + } + @OptIn(ExperimentalCoroutinesApi::class) fun loadPeopleListFlowFromListName(listName: String): Flow = if (listName != GLOBAL_FOLLOWS && listName != KIND3_FOLLOWS) { @@ -467,26 +469,32 @@ class Account( MutableStateFlow(ListNameNotePair(listName, null)) } + suspend fun combinePeopleList( + kind3Follows: LiveFollowLists, + peopleListFollows: ListNameNotePair, + ): LiveFollowLists? = + if (peopleListFollows.listName == GLOBAL_FOLLOWS) { + null + } else if (peopleListFollows.listName == KIND3_FOLLOWS) { + kind3Follows + } else if (peopleListFollows.event == null) { + LiveFollowLists(usersPlusMe = setOf(signer.pubKey)) + } else { + val result = waitToDecrypt(peopleListFollows.event) + if (result == null) { + LiveFollowLists(usersPlusMe = setOf(signer.pubKey)) + } else { + result + } + } + fun combinePeopleListFlows( kind3FollowsSource: Flow, peopleListFollowsSource: Flow, ): Flow = combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows -> checkNotInMainThread() - if (peopleListFollows.listName == GLOBAL_FOLLOWS) { - emit(null) - } else if (peopleListFollows.listName == KIND3_FOLLOWS) { - emit(kind3Follows) - } else if (peopleListFollows.event == null) { - emit(LiveFollowLists(usersPlusMe = setOf(signer.pubKey))) - } else { - val result = waitToDecrypt(peopleListFollows.event) - if (result == null) { - emit(LiveFollowLists(usersPlusMe = setOf(signer.pubKey))) - } else { - emit(result) - } - } + emit(combinePeopleList(kind3Follows, peopleListFollows)) } val liveHomeFollowListFlow: Flow by lazy { @@ -494,7 +502,18 @@ class Account( } val liveHomeFollowLists: StateFlow by lazy { - liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(signer.pubKey))) + liveHomeFollowListFlow + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + runBlocking { + combinePeopleList( + liveKind3Follows.value, + peopleListFromListNameStarter(settings.defaultHomeFollowList.value), + ) + }, + ) } /** @@ -542,29 +561,47 @@ class Account( } fun authorsPerRelay( - followsNIP65RelayLists: Array, + followsNIP65RelayLists: List, defaultRelayList: List, - ): Map> = - assembleAuthorsPerWriteRelay( + ): Map> { + checkNotInMainThread() + + val defaultSet = defaultRelayList.toSet() + + return assembleAuthorsPerWriteRelay( followsNIP65RelayLists .mapNotNull { - val author = (it.note as? AddressableNote)?.address?.pubKeyHex - val event = (it.note.event as? AdvertisedRelayListEvent) + val author = (it as? AddressableNote)?.address?.pubKeyHex + val event = (it.event as? AdvertisedRelayListEvent) if (event != null) { - event.pubKey to event.writeRelays() + val authorWriteRelays = + event.writeRelays().map { + RelayUrlFormatter.normalize(it) + } + + val commonRelaysToMe = authorWriteRelays.filter { it in defaultSet } + if (commonRelaysToMe.isNotEmpty()) { + println("Assemble NIP65 Refined: $author ${commonRelaysToMe.joinToString(", ") { it }}") + event.pubKey to commonRelaysToMe + } else { + println("Assemble NIP65 Default: $author ${defaultSet.size}") + event.pubKey to defaultRelayList + } } else { if (author != null) { + println("Assemble NIP65 Default: $author ${defaultSet.size}") author to defaultRelayList } else { - Log.e("Account", "This author should NEVER be null. Note: ${it.note.idHex}") + Log.e("Account", "This author should NEVER be null. Note: ${it.idHex}") null } } }.toMap(), - hasOnionConnection = proxy != null, + hasOnionConnection = settings.proxy != null, ) + } @OptIn(ExperimentalCoroutinesApi::class) val liveHomeFollowListAdvertizedRelayListFlow: Flow?> = @@ -580,7 +617,12 @@ class Account( val liveHomeListAuthorsPerRelayFlow: Flow>?> by lazy { combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing -> if (adverisedRelayList != null) { - emit(authorsPerRelay(adverisedRelayList, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url })) + emit( + authorsPerRelay( + adverisedRelayList.map { it.note }, + existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, + ), + ) } else { emit(null) } @@ -588,37 +630,58 @@ class Account( } val liveHomeListAuthorsPerRelay: StateFlow>?> by lazy { - val currentRelays = connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url } - val default = - currentRelays.associate { relayUrl -> - relayUrl to (liveHomeFollowLists.value?.usersPlusMe?.toList() ?: emptyList()) - } - - liveHomeListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, default) + liveHomeListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( + scope, + SharingStarted.Eagerly, + authorsPerRelay( + liveHomeFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), + connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, + ), + ) } @OptIn(ExperimentalCoroutinesApi::class) private val liveNotificationList: Flow by lazy { - defaultNotificationFollowList.flatMapLatest { listName -> + settings.defaultNotificationFollowList.flatMapLatest { listName -> loadPeopleListFlowFromListName(listName) } } val liveNotificationFollowLists: StateFlow by lazy { combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList) - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(signer.pubKey))) + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + runBlocking { + combinePeopleList( + liveKind3Follows.value, + peopleListFromListNameStarter(settings.defaultNotificationFollowList.value), + ) + }, + ) } @OptIn(ExperimentalCoroutinesApi::class) private val liveStoriesList: Flow by lazy { - defaultStoriesFollowList.flatMapLatest { listName -> + settings.defaultStoriesFollowList.flatMapLatest { listName -> loadPeopleListFlowFromListName(listName) } } val liveStoriesFollowLists: StateFlow by lazy { combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList) - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(signer.pubKey))) + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + runBlocking { + combinePeopleList( + liveKind3Follows.value, + peopleListFromListNameStarter(settings.defaultStoriesFollowList.value), + ) + }, + ) } @OptIn(ExperimentalCoroutinesApi::class) @@ -635,7 +698,7 @@ class Account( val liveStoriesListAuthorsPerRelayFlow: Flow>?> by lazy { combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing -> if (adverisedRelayList != null) { - emit(authorsPerRelay(adverisedRelayList, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url })) + emit(authorsPerRelay(adverisedRelayList.map { it.note }, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url })) } else { emit(null) } @@ -643,19 +706,36 @@ class Account( } val liveStoriesListAuthorsPerRelay: StateFlow>?> by lazy { - liveStoriesListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + liveStoriesListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( + scope, + SharingStarted.Eagerly, + authorsPerRelay( + liveStoriesFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), + connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, + ), + ) } @OptIn(ExperimentalCoroutinesApi::class) private val liveDiscoveryList: Flow by lazy { - defaultDiscoveryFollowList.flatMapLatest { listName -> + settings.defaultDiscoveryFollowList.flatMapLatest { listName -> loadPeopleListFlowFromListName(listName) } } val liveDiscoveryFollowLists: StateFlow by lazy { combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList) - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(signer.pubKey))) + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + runBlocking { + combinePeopleList( + liveKind3Follows.value, + peopleListFromListNameStarter(settings.defaultDiscoveryFollowList.value), + ) + }, + ) } @OptIn(ExperimentalCoroutinesApi::class) @@ -672,7 +752,7 @@ class Account( val liveDiscoveryListAuthorsPerRelayFlow: Flow>?> by lazy { combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing -> if (adverisedRelayList != null) { - emit(authorsPerRelay(adverisedRelayList, existing.filter { it.read }.map { it.url })) + emit(authorsPerRelay(adverisedRelayList.map { it.note }, existing.filter { it.read }.map { it.url })) } else { emit(null) } @@ -680,7 +760,14 @@ class Account( } val liveDiscoveryListAuthorsPerRelay: StateFlow>?> by lazy { - liveDiscoveryListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + liveDiscoveryListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( + scope, + SharingStarted.Eagerly, + authorsPerRelay( + liveDiscoveryFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), + connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, + ), + ) } private fun decryptLiveFollows( @@ -728,55 +815,69 @@ class Account( val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) } } + suspend fun decryptPeopleList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords { + if (event == null) return PeopleListEvent.UsersAndWords() + + return withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + event.publicAndPrivateUsersAndWords(signer) { + continuation.resume(it) + } + } + } ?: PeopleListEvent.UsersAndWords() + } + + suspend fun decryptMuteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords { + if (event == null) return PeopleListEvent.UsersAndWords() + + return withTimeoutOrNull(1000) { + suspendCancellableCoroutine { continuation -> + event.publicAndPrivateUsersAndWords(signer) { + continuation.resume(it) + } + } + } ?: PeopleListEvent.UsersAndWords() + } + + suspend fun assembleLiveHiddenUsers( + blockList: Note, + muteList: Note, + transientHiddenUsers: Set, + showSensitiveContent: Boolean?, + ): LiveHiddenUsers { + val resultBlockList = decryptPeopleList(blockList.event as? PeopleListEvent) + val resultMuteList = decryptMuteList(muteList.event as? MuteListEvent) + + return LiveHiddenUsers( + hiddenUsers = resultBlockList.users + resultMuteList.users, + hiddenWords = resultBlockList.words + resultMuteList.words, + spammers = transientHiddenUsers, + showSensitiveContent = showSensitiveContent, + ) + } + val flowHiddenUsers: StateFlow by lazy { combineTransform( - transientHiddenUsers, - showSensitiveContent, getBlockListNote().flow().metadata.stateFlow, getMuteListNote().flow().metadata.stateFlow, - ) { transientHiddenUsers, showSensitiveContent, blockList, muteList -> + transientHiddenUsers, + settings.showSensitiveContent, + ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> checkNotInMainThread() - - val resultBlockList = - (blockList.note.event as? PeopleListEvent)?.let { - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } - } - } - } - ?: PeopleListEvent.UsersAndWords() - - val resultMuteList = - (muteList.note.event as? MuteListEvent)?.let { - withTimeoutOrNull(1000) { - suspendCancellableCoroutine { continuation -> - it.publicAndPrivateUsersAndWords(signer) { continuation.resume(it) } - } - } - } - ?: PeopleListEvent.UsersAndWords() - - val hiddenWords = resultBlockList.words + resultMuteList.words - - emit( - LiveHiddenUsers( - hiddenUsers = (resultBlockList.users + resultMuteList.users), - hiddenWords = hiddenWords, - spammers = transientHiddenUsers, - showSensitiveContent = showSensitiveContent, - ), + emit(assembleLiveHiddenUsers(blockList.note, muteList.note, transientHiddenUsers, showSensitiveContent)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + runBlocking { + assembleLiveHiddenUsers( + getBlockListNote(), + getMuteListNote(), + transientHiddenUsers.value, + settings.showSensitiveContent.value, + ) + }, ) - }.stateIn( - scope, - SharingStarted.Eagerly, - LiveHiddenUsers( - hiddenUsers = setOf(), - hiddenWords = setOf(), - spammers = transientHiddenUsers.value, - showSensitiveContent = showSensitiveContent.value, - ), - ) } val liveHiddenUsers = flowHiddenUsers.asLiveData() @@ -806,45 +907,37 @@ class Account( !this.transientPaymentRequests.value.contains(paymentRequest) && !this.transientPaymentRequestDismissals.contains(paymentRequest) ) { - this.transientPaymentRequests.value = transientPaymentRequests.value + paymentRequest + this.transientPaymentRequests.value += paymentRequest } } fun dismissPaymentRequest(request: PaymentRequest) { if (this.transientPaymentRequests.value.contains(request)) { - this.transientPaymentRequests.value = transientPaymentRequests.value - request - this.transientPaymentRequestDismissals = transientPaymentRequestDismissals + request + this.transientPaymentRequests.value -= request + this.transientPaymentRequestDismissals += request } } - var userProfileCache: User? = null + private var userProfileCache: User? = null + + fun userProfile(): User = userProfileCache ?: LocalCache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + + fun isWriteable(): Boolean = settings.isWriteable() fun updateOptOutOptions( warnReports: Boolean, filterSpam: Boolean, ) { - warnAboutPostsWithReports = warnReports - filterSpamFromStrangers = filterSpam - LocalCache.antiSpam.active = filterSpamFromStrangers - if (!filterSpamFromStrangers) { - transientHiddenUsers.update { - emptySet() + if (settings.updateOptOutOptions(warnReports, filterSpam)) { + LocalCache.antiSpam.active = settings.filterSpamFromStrangers + if (!settings.filterSpamFromStrangers) { + transientHiddenUsers.update { + emptySet() + } } } - live.invalidateData() - saveable.invalidateData() } - fun userProfile(): User = - userProfileCache - ?: run { - val myUser: User = LocalCache.getOrCreateUser(keyPair.pubKey.toHexKey()) - userProfileCache = myUser - myUser - } - - fun isWriteable(): Boolean = keyPair.privKey != null || signer is NostrSignerExternal - fun sendKind3RelayList(relays: Map) { if (!isWriteable()) return @@ -1037,21 +1130,27 @@ class Account( ?.filter { it.value.read } ?.keys ?.ifEmpty { null } - ?: localRelays.filter { it.read }.map { it.url }.toSet() + ?: settings.localRelays + .filter { it.read } + .map { it.url } + .toSet() - fun hasWalletConnectSetup(): Boolean = zapPaymentRequest != null + fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest != null fun isNIP47Author(pubkeyHex: String?): Boolean = (getNIP47Signer().pubKey == pubkeyHex) fun getNIP47Signer(): NostrSigner = - zapPaymentRequest?.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } + settings.zapPaymentRequest + ?.secret + ?.hexToByteArray() + ?.let { NostrSignerInternal(KeyPair(it)) } ?: signer fun decryptZapPaymentResponseEvent( zapResponseEvent: LnZapPaymentResponseEvent, onReady: (Response) -> Unit, ) { - val myNip47 = zapPaymentRequest ?: return + val myNip47 = settings.zapPaymentRequest ?: return val signer = myNip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer @@ -1081,7 +1180,7 @@ class Account( ) { if (!isWriteable()) return - zapPaymentRequest?.let { nip47 -> + settings.zapPaymentRequest?.let { nip47 -> val signer = nip47.secret?.hexToByteArray()?.let { NostrSignerInternal(KeyPair(it)) } ?: signer @@ -1117,7 +1216,7 @@ class Account( ?.relays() ?.keys ?.ifEmpty { null } - ?: localRelays.map { it.url }.toSet(), + ?: settings.localRelays.map { it.url }.toSet(), signer, message, zapType, @@ -1248,9 +1347,9 @@ class Account( } suspend fun updateAttestations() { - Log.d("Pending Attestations", "Updating ${pendingAttestations.value.size} pending attestations") + Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") - pendingAttestations.value.forEach { pair -> + settings.pendingAttestations.value.forEach { pair -> val newAttestation = OtsEvent.upgrade(pair.value, pair.key) if (pair.value != newAttestation) { @@ -1258,7 +1357,7 @@ class Account( LocalCache.justConsume(it, null) Client.send(it) - pendingAttestations.update { + settings.pendingAttestations.update { it - pair.key } } @@ -1268,7 +1367,7 @@ class Account( fun hasPendingAttestations(note: Note): Boolean { val id = note.event?.id() ?: note.idHex - return pendingAttestations.value[id] != null + return settings.pendingAttestations.value[id] != null } fun timestamp(note: Note) { @@ -1277,11 +1376,7 @@ class Account( val id = note.event?.id() ?: note.idHex - pendingAttestations.update { - it + Pair(id, OtsEvent.stamp(id)) - } - - saveable.invalidateData() + settings.addPendingAttestation(id, OtsEvent.stamp(id)) } fun follow(user: User) { @@ -2308,7 +2403,7 @@ class Account( signedEvents.wraps.forEach { wrap -> // Creates an alias - if (mineNote != null && wrap.recipientPubKey() != keyPair.pubKey.toHexKey()) { + if (mineNote != null && wrap.recipientPubKey() != signer.pubKey) { LocalCache.getOrAddAliasNote(wrap.id, mineNote) } @@ -2620,6 +2715,8 @@ class Account( return LocalCache.getOrCreateAddressableNote(aTag) } + fun getMuteListFlow(): StateFlow = getMuteListNote().flow().metadata.stateFlow + fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent @@ -2736,62 +2833,6 @@ class Account( transientHiddenUsers.update { it - pubkeyHex } - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { - defaultZapType.tryEmit(zapType) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { - defaultFileServer = server - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultHomeFollowList(name: String) { - defaultHomeFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultStoriesFollowList(name: String) { - defaultStoriesFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultNotificationFollowList(name: String) { - defaultNotificationFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeDefaultDiscoveryFollowList(name: String) { - defaultDiscoveryFollowList.tryEmit(name) - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapAmounts(newAmounts: List) { - zapAmountChoices = newAmounts - live.invalidateData() - saveable.invalidateData() - } - - fun changeReactionTypes(newTypes: List) { - reactionChoices = newTypes - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) { - zapPaymentRequest = newServer - live.invalidateData() - saveable.invalidateData() } fun selectedChatsFollowList(): Set { @@ -2904,76 +2945,19 @@ class Account( } } - fun updateLocalRelayServers(servers: Set) { - localRelayServers = servers - saveable.invalidateData() - } - - fun addDontTranslateFrom(languageCode: String) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - saveable.invalidateData() - } - - fun updateTranslateTo(languageCode: String) { - translateTo = languageCode - saveable.invalidateData() - } - - fun prefer( - source: String, - target: String, - preference: String, - ) { - languagePreferences = languagePreferences + Pair("$source,$target", preference) - saveable.invalidateData() - } - - fun preferenceBetween( - source: String, - target: String, - ): String? = languagePreferences.get("$source,$target") - - private fun updateContactListTo(newContactList: ContactListEvent?) { - if (newContactList == null || newContactList.tags.isEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupContactList?.id != newContactList.id) { - backupContactList = newContactList - saveable.invalidateData() - } - } - - private fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) { - if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupDMRelayList?.id != newDMRelayList.id) { - backupDMRelayList = newDMRelayList - saveable.invalidateData() - } - } - - private fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) { - if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupNIP65RelayList?.id != newNIP65RelayList.id) { - backupNIP65RelayList = newNIP65RelayList - saveable.invalidateData() - } - } - // Takes a User's relay list and adds the types of feeds they are active for. - fun activeRelays(): Array? { + fun kind3Relays(): Array? { val usersRelayList = - userProfile() - .latestContactList + (userProfile().latestContactList ?: settings.backupContactList) ?.relays() ?.map { val url = RelayUrlFormatter.normalize(it.key) val localFeedTypes = - localRelays.firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url }?.feedTypes?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)) + settings.localRelays + .firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url } + ?.feedTypes + ?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)) ?: Constants.defaultRelays .filter { defaultRelay -> RelayUrlFormatter.normalize(defaultRelay.url) == url } .firstOrNull() @@ -2987,7 +2971,7 @@ class Account( } fun convertLocalRelays(): Array = - localRelays + settings.localRelays .map { RelaySetupInfo( RelayUrlFormatter.normalize(it.url), @@ -3024,7 +3008,7 @@ class Account( return true } - if (!warnAboutPostsWithReports) { + if (!settings.warnAboutPostsWithReports) { return !isHidden(user) && // if user hasn't hided this author user.reportsBy(userProfile()).isEmpty() // if user has not reported this post @@ -3037,7 +3021,7 @@ class Account( } private fun isAcceptableDirect(note: Note): Boolean { - if (!warnAboutPostsWithReports) { + if (!settings.warnAboutPostsWithReports) { return !note.hasReportsBy(userProfile()) } return !note.hasReportsBy(userProfile()) && @@ -3078,14 +3062,10 @@ class Account( } fun saveKind3RelayList(value: List) { - try { - localRelays = value.toSet() - return sendKind3RelayList( - value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, - ) - } finally { - saveable.invalidateData() - } + settings.updateLocalRelays(value.toSet()) + sendKind3RelayList( + value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }, + ) } fun getDMRelayListNote(): AddressableNote = @@ -3265,135 +3245,147 @@ class Account( (event.hasAnyTaggedUser() || event.publicAndPrivateUserCache?.isNotEmpty() == true) } - fun setHideDeleteRequestDialog() { - hideDeleteRequestDialog = true - saveable.invalidateData() - } - - fun setHideNIP17WarningDialog() { - hideNIP17WarningDialog = true - saveable.invalidateData() - } - - fun setHideBlockAlertDialog() { - hideBlockAlertDialog = true - saveable.invalidateData() - } - - fun updateShowSensitiveContent(show: Boolean?) { - showSensitiveContent.update { - show - } - saveable.invalidateData() - live.invalidateData() - } + fun updateShowSensitiveContent(show: Boolean?) = settings.updateShowSensitiveContent(show) fun markAsRead( route: String, timestampInSecs: Long, - ): Boolean { - val lastTime = lastReadPerRoute.value[route] - return if (lastTime == null) { - lastReadPerRoute.update { - it + Pair(route, MutableStateFlow(timestampInSecs)) - } - saveable.invalidateData() - true - } else if (timestampInSecs > lastTime.value) { - lastTime.tryEmit(timestampInSecs) - saveable.invalidateData() - true - } else { - false - } - } + ) = settings.markAsRead(route, timestampInSecs) - fun loadLastRead(route: String): Long = lastReadPerRoute.value[route]?.value ?: 0 + fun loadLastRead(route: String): Long = settings.lastReadPerRoute.value[route]?.value ?: 0 - fun loadLastReadFlow(route: String): StateFlow = - lastReadPerRoute.value[route] ?: run { - val newFlow = MutableStateFlow(0) - lastReadPerRoute.update { - it + Pair(route, newFlow) - } - newFlow + fun loadLastReadFlow(route: String) = settings.getLastReadFlow(route) + + fun hasDonatedInThisVersion() = settings.hasDonatedInVersion(BuildConfig.VERSION_NAME) + + fun observeDonatedInThisVersion() = + settings + .observeDonatedInVersion(BuildConfig.VERSION_NAME) + .flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, hasDonatedInThisVersion()) + + fun markDonatedInThisVersion() = settings.markDonatedInThisVersion(BuildConfig.VERSION_NAME) + + init { + Log.d("AccountRegisterObservers", "Init") + settings.backupContactList?.let { + Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") + + GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it) } } - fun hasDonatedInThisVersion(): Boolean = hasDonatedInVersion.contains(BuildConfig.VERSION_NAME) + settings.backupUserMetadata?.let { + Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}") - fun markDonatedInThisVersion() { - hasDonatedInVersion = hasDonatedInVersion + BuildConfig.VERSION_NAME - saveable.invalidateData() - live.invalidateData() - } + GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it, null) } + } - suspend fun registerObservers() = - withContext(Dispatchers.Main) { - // saves contact list for the next time. - scope.launch { - Log.d("AccountRegisterObservers", "Kind 3 Collector Start") - userProfile().flow().follows.stateFlow.collect { - Log.d("AccountRegisterObservers", "Updating Kind 3 ${userProfile().toBestDisplayName()}") - updateContactListTo(userProfile().latestContactList) + settings.backupDMRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}") + GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } + } + + settings.backupNIP65RelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") + GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } + } + + settings.backupSearchRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") + GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } + } + + settings.backupPrivateHomeRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") + GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } + } + + settings.backupMuteList?.let { + Log.d("AccountRegisterObservers", "Loading saved mute list ${it.toJson()}") + GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Kind 0 Collector Start") + userProfile().flow().metadata.stateFlow.collect { + Log.d("AccountRegisterObservers", "Updating Kind 0 ${userProfile().toBestDisplayName()}") + settings.updateUserMetadata(userProfile().latestMetadata) + } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Kind 3 Collector Start") + userProfile().flow().follows.stateFlow.collect { + Log.d("AccountRegisterObservers", "Updating Kind 3 ${userProfile().toBestDisplayName()}") + settings.updateContactListTo(userProfile().latestContactList) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") + getDMRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating DM Relay List for ${userProfile().toBestDisplayName()}") + (it.note.event as? ChatMessageRelayListEvent)?.let { + settings.updateDMRelayList(it) } } + } - scope.launch { - Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") - getDMRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating DM Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? ChatMessageRelayListEvent)?.let { - updateDMRelayList(it) - } + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") + getNIP65RelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${userProfile().toBestDisplayName()}") + (it.note.event as? AdvertisedRelayListEvent)?.let { + settings.updateNIP65RelayList(it) } } + } - scope.launch { - Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") - getNIP65RelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${userProfile().toBestDisplayName()}") - (it.note.event as? AdvertisedRelayListEvent)?.let { - updateNIP65RelayList(it) - } + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Search Relay List Collector Start") + getSearchRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Search Relay List for ${userProfile().toBestDisplayName()}") + (it.note.event as? SearchRelayListEvent)?.let { + settings.updateSearchRelayList(it) } } + } - // imports transient blocks due to spam. - LocalCache.antiSpam.liveSpam.observeForever { - GlobalScope.launch(Dispatchers.IO) { - it.cache.spamMessages.snapshot().values.forEach { spammer -> - if (spammer.pubkeyHex !in transientHiddenUsers.value && spammer.duplicatedMessages.size >= 5) { - if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) { - transientHiddenUsers.update { - it + spammer.pubkeyHex - } - live.invalidateData() + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") + getPrivateOutboxRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${userProfile().toBestDisplayName()}") + (it.note.event as? PrivateOutboxRelayListEvent)?.let { + settings.updatePrivateHomeRelayList(it) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Mute List Collector Start") + getMuteListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Mute List for ${userProfile().toBestDisplayName()}") + (it.note.event as? MuteListEvent)?.let { + settings.updateMuteList(it) + } + } + } + + scope.launch(Dispatchers.Default) { + LocalCache.antiSpam.flowSpam.collect { + it.cache.spamMessages.snapshot().values.forEach { spammer -> + if (spammer.pubkeyHex !in transientHiddenUsers.value && spammer.duplicatedMessages.size >= 5) { + if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) { + transientHiddenUsers.update { + it + spammer.pubkeyHex } } } } } } - - init { - Log.d("Account", "Init") - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - - if (userProfile().latestContactList == null) { - GlobalScope.launch(Dispatchers.IO) { LocalCache.consume(it) } - } - } - - backupDMRelayList?.let { - println("Loading DM Relay List ${it.toJson()}") - GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } - } - - backupNIP65RelayList?.let { - println("Loading saved contacts ${it.toJson()}") - GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt new file mode 100644 index 000000000..c8fced48d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -0,0 +1,522 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import android.content.res.Resources +import androidx.compose.runtime.Stable +import androidx.core.os.ConfigurationCompat +import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.ammolite.relays.Constants +import com.vitorpamplona.ammolite.relays.RelaySetupInfo +import com.vitorpamplona.ammolite.service.HttpClientManager +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip47WalletConnect +import com.vitorpamplona.quartz.encoders.RelayUrlFormatter +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.events.ContactListEvent +import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.MetadataEvent +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.events.SearchRelayListEvent +import com.vitorpamplona.quartz.signers.ExternalSignerLauncher +import com.vitorpamplona.quartz.signers.NostrSignerExternal +import com.vitorpamplona.quartz.signers.NostrSignerInternal +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import java.net.Proxy +import java.util.Locale + +val DefaultChannels = + setOf( + // Anigma's Nostr + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", + // Amethyst's Group + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", + ) + +val DefaultReactions = + listOf( + "\uD83D\uDE80", + "\uD83E\uDEC2", + "\uD83D\uDC40", + "\uD83D\uDE02", + "\uD83C\uDF89", + "\uD83E\uDD14", + "\uD83D\uDE31", + ) + +val DefaultNIP65List = + listOf( + AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), + AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), + AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), + ) + +val DefaultDMRelayList = + listOf( + RelayUrlFormatter.normalize("wss://auth.nostr1.com/"), + RelayUrlFormatter.normalize("wss://nostr.mom/"), + RelayUrlFormatter.normalize("wss://nos.lol/"), + ) + +val DefaultSearchRelayList = + listOf( + RelayUrlFormatter.normalize("wss://relay.nostr.band"), + RelayUrlFormatter.normalize("wss://nostr.wine"), + RelayUrlFormatter.normalize("wss://relay.noswhere.com"), + ) + +val DefaultZapAmounts = listOf(100L, 500L, 1000L) + +fun getLanguagesSpokenByUser(): Set { + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + val codedList = mutableSetOf() + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { codedList.add(it.language) } + } + return codedList +} + +// This has spaces to avoid mixing with a potential NIP-51 list with the same name. +val GLOBAL_FOLLOWS = " Global " + +// This has spaces to avoid mixing with a potential NIP-51 list with the same name. +val KIND3_FOLLOWS = " All Follows " + +@Stable +class AccountSettings( + val keyPair: KeyPair, + var externalSignerPackageName: String? = null, + var localRelays: Set = Constants.defaultRelays.toSet(), + var localRelayServers: Set = setOf(), + var dontTranslateFrom: Set = getLanguagesSpokenByUser(), + var languagePreferences: Map = mapOf(), + var translateTo: String = Locale.getDefault().language, + var zapAmountChoices: MutableStateFlow> = MutableStateFlow(DefaultZapAmounts), + var reactionChoices: MutableStateFlow> = MutableStateFlow(DefaultReactions), + val defaultZapType: MutableStateFlow = MutableStateFlow(LnZapEvent.ZapType.PUBLIC), + var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], + val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), + val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null, + var hideDeleteRequestDialog: Boolean = false, + var hideBlockAlertDialog: Boolean = false, + var hideNIP17WarningDialog: Boolean = false, + var backupUserMetadata: MetadataEvent? = null, + var backupContactList: ContactListEvent? = null, + var backupDMRelayList: ChatMessageRelayListEvent? = null, + var backupNIP65RelayList: AdvertisedRelayListEvent? = null, + var backupSearchRelayList: SearchRelayListEvent? = null, + var backupMuteList: MuteListEvent? = null, + var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null, + var proxy: Proxy? = null, + var proxyPort: Int = 9050, + val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), + var warnAboutPostsWithReports: Boolean = true, + var filterSpamFromStrangers: Boolean = true, + val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), + var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), + val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), +) { + val saveable = MutableStateFlow(AccountSettingsUpdater(this)) + + fun saveAccountSettings() { + saveable.update { AccountSettingsUpdater(this) } + } + + fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null + + fun createSigner() = + if (keyPair.privKey != null) { + NostrSignerInternal(keyPair) + } else { + when (val packageName = externalSignerPackageName) { + null -> NostrSignerInternal(keyPair) + else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toNpub(), packageName)) + } + } + + // --- + // Zaps and Reactions + // --- + + fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { + if (defaultZapType.value != zapType) { + defaultZapType.tryEmit(zapType) + saveAccountSettings() + } + } + + fun changeZapAmounts(newAmounts: List) { + if (zapAmountChoices.value != newAmounts) { + zapAmountChoices.tryEmit(newAmounts) + saveAccountSettings() + } + } + + fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) { + if (zapPaymentRequest != newServer) { + zapPaymentRequest = newServer + saveAccountSettings() + } + } + + fun changeReactionTypes(newTypes: List) { + if (reactionChoices.value != newTypes) { + reactionChoices.tryEmit(newTypes) + saveAccountSettings() + } + } + + // --- + // file servers + // --- + + fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { + if (defaultFileServer != server) { + defaultFileServer = server + saveAccountSettings() + } + } + + // --- + // list names + // --- + + fun changeDefaultHomeFollowList(name: String) { + if (defaultHomeFollowList.value != name) { + defaultHomeFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultStoriesFollowList(name: String) { + if (defaultStoriesFollowList.value != name) { + defaultStoriesFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultNotificationFollowList(name: String) { + if (defaultNotificationFollowList.value != name) { + defaultNotificationFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultDiscoveryFollowList(name: String) { + if (defaultDiscoveryFollowList.value != name) { + defaultDiscoveryFollowList.tryEmit(name) + saveAccountSettings() + } + } + + // --- + // proxy settings + // --- + + fun isProxyEnabled() = proxy != null + + fun updateProxy( + enabled: Boolean, + portNumber: String, + ) { + val port = portNumber.toIntOrNull() ?: return + if (proxyPort != port && isProxyEnabled() != enabled) { + proxyPort = portNumber.toInt() + proxy = HttpClientManager.initProxy(enabled, "127.0.0.1", proxyPort) + saveAccountSettings() + } + } + + // --- + // language services + // --- + fun addDontTranslateFrom(languageCode: String) { + if (!dontTranslateFrom.contains(languageCode)) { + dontTranslateFrom = dontTranslateFrom.plus(languageCode) + saveAccountSettings() + } + } + + fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language) + + fun updateTranslateTo(languageCode: Locale) { + if (translateTo != languageCode.language) { + translateTo = languageCode.language + saveAccountSettings() + } + } + + fun prefer( + source: String, + target: String, + preference: String, + ) { + val key = "$source,$target" + if (key !in languagePreferences) { + languagePreferences = languagePreferences + Pair(key, preference) + saveAccountSettings() + } + } + + fun preferenceBetween( + source: String, + target: String, + ): String? = languagePreferences["$source,$target"] + + // ---- + // Backup Lists + // ---- + + fun updateLocalRelayServers(servers: Set) { + if (localRelayServers != servers) { + localRelayServers = servers + saveAccountSettings() + } + } + + fun updateUserMetadata(newMetadata: MetadataEvent?) { + if (newMetadata == null) return + + // Events might be different objects, we have to compare their ids. + if (backupUserMetadata?.id != newMetadata.id) { + backupUserMetadata = newMetadata + saveAccountSettings() + } + } + + fun updateContactListTo(newContactList: ContactListEvent?) { + if (newContactList == null || newContactList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupContactList?.id != newContactList.id) { + backupContactList = newContactList + saveAccountSettings() + } + } + + fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) { + if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupDMRelayList?.id != newDMRelayList.id) { + backupDMRelayList = newDMRelayList + saveAccountSettings() + } + } + + fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) { + if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupNIP65RelayList?.id != newNIP65RelayList.id) { + backupNIP65RelayList = newNIP65RelayList + saveAccountSettings() + } + } + + fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) { + if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupSearchRelayList?.id != newSearchRelayList.id) { + backupSearchRelayList = newSearchRelayList + saveAccountSettings() + } + } + + fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) { + if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) { + backupPrivateHomeRelayList = newPrivateHomeRelayList + saveAccountSettings() + } + } + + fun updateMuteList(newMuteList: MuteListEvent?) { + if (newMuteList == null || newMuteList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupMuteList?.id != newMuteList.id) { + backupMuteList = newMuteList + saveAccountSettings() + } + } + + // ---- + // Warning dialogs + // ---- + + fun setHideDeleteRequestDialog() { + if (!hideDeleteRequestDialog) { + hideDeleteRequestDialog = true + saveAccountSettings() + } + } + + fun setHideNIP17WarningDialog() { + if (!hideNIP17WarningDialog) { + hideNIP17WarningDialog = true + saveAccountSettings() + } + } + + fun setHideBlockAlertDialog() { + if (!hideBlockAlertDialog) { + hideBlockAlertDialog = true + saveAccountSettings() + } + } + + fun updateShowSensitiveContent(show: Boolean?): Boolean { + if (showSensitiveContent.value != show) { + showSensitiveContent.update { show } + saveAccountSettings() + return true + } + return false + } + + // --- + // donations + // --- + + fun hasDonatedInVersion(versionName: String) = hasDonatedInVersion.value.contains(versionName) + + fun observeDonatedInVersion(versionName: String) = + hasDonatedInVersion + .map { + it.contains(versionName) + } + + fun markDonatedInThisVersion(versionName: String): Boolean { + if (!hasDonatedInVersion.value.contains(versionName)) { + hasDonatedInVersion.update { + it + BuildConfig.VERSION_NAME + } + saveAccountSettings() + return true + } + return false + } + + // ---- + // last read flows + // ---- + + fun getLastReadFlow(route: String): StateFlow = lastReadPerRoute.value[route] ?: addLastRead(route, 0) + + private fun addLastRead( + route: String, + timestampInSecs: Long, + ): MutableStateFlow = + MutableStateFlow(timestampInSecs).also { newFlow -> + lastReadPerRoute.update { it + Pair(route, newFlow) } + saveAccountSettings() + } + + fun markAsRead( + route: String, + timestampInSecs: Long, + ): Boolean { + val lastTime = lastReadPerRoute.value[route] + return if (lastTime == null) { + addLastRead(route, timestampInSecs) + true + } else if (timestampInSecs > lastTime.value) { + lastTime.tryEmit(timestampInSecs) + saveAccountSettings() + true + } else { + false + } + } + + // ---- + // local relays + // ---- + + fun updateLocalRelays(newLocalRelays: Set) { + if (!localRelays.equals(newLocalRelays)) { + localRelays = newLocalRelays + saveAccountSettings() + } + } + + // --- + // attestations + // --- + + fun addPendingAttestation( + id: HexKey, + stamp: String, + ) { + val current = pendingAttestations.value.get(id) + if (current == null) { + pendingAttestations.update { + it + Pair(id, stamp) + } + saveAccountSettings() + } else { + if (current != stamp) { + pendingAttestations.update { + it + Pair(id, stamp) + } + saveAccountSettings() + } + } + } + + // --- + // filters + // --- + fun updateOptOutOptions( + warnReports: Boolean, + filterSpam: Boolean, + ): Boolean = + if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) { + warnAboutPostsWithReports = warnReports + filterSpamFromStrangers = filterSpam + + saveAccountSettings() + + true + } else { + false + } +} + +class AccountSettingsUpdater( + val accountSettings: AccountSettings, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index e191b980a..84663723e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -22,17 +22,14 @@ package com.vitorpamplona.amethyst.model import android.util.Log import android.util.LruCache -import androidx.compose.runtime.Stable -import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.njumpLink -import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.ammolite.relays.RelayStats import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.events.Event -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow data class Spammer( val pubkeyHex: HexKey, @@ -86,7 +83,7 @@ class AntiSpamFilter { RelayStats.newSpam(relay.url, njumpLink(Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url))) } - liveSpam.invalidateData() + flowSpam.tryEmit(AntiSpamState(this)) return true } @@ -109,27 +106,7 @@ class AntiSpamFilter { } } - val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) -} - -@Stable -class AntiSpamLiveData( - val cache: AntiSpamFilter, -) : LiveData(AntiSpamState(cache)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.IO) - - fun invalidateData() { - checkNotInMainThread() - - bundler.invalidate { - checkNotInMainThread() - - if (hasActiveObservers()) { - postValue(AntiSpamState(cache)) - } - } - } + val flowSpam = MutableStateFlow(AntiSpamState(this)) } class AntiSpamState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index bbd773dea..ff881c730 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -331,6 +331,7 @@ class User( } } + flowSet?.metadata?.invalidateData() liveSet?.innerMetadata?.invalidateData() } @@ -434,12 +435,17 @@ class UserFlowSet( u: User, ) { // Observers line up here. + val metadata = UserBundledRefresherFlow(u) val follows = UserBundledRefresherFlow(u) val relays = UserBundledRefresherFlow(u) - fun isInUse(): Boolean = relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0 + fun isInUse(): Boolean = + metadata.stateFlow.subscriptionCount.value > 0 || + relays.stateFlow.subscriptionCount.value > 0 || + follows.stateFlow.subscriptionCount.value > 0 fun destroy() { + metadata.destroy() relays.destroy() follows.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index cfcdc847c..c01127f2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -164,7 +164,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultNotificationFollowList.value) + ?.get(account.settings.defaultNotificationFollowList.value) ?.relayList, ), ) @@ -183,7 +183,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { var since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultNotificationFollowList.value) + ?.get(account.settings.defaultNotificationFollowList.value) ?.relayList ?.toMutableMap() @@ -229,7 +229,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { val since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultNotificationFollowList.value) + ?.get(account.settings.defaultNotificationFollowList.value) ?.relayList ?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } ?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) } @@ -278,7 +278,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { if (hasLoadedTheBasics[account.userProfile()] != null) { latestEOSEs.addOrUpdate( account.userProfile(), - account.defaultNotificationFollowList.value, + account.settings.defaultNotificationFollowList.value, relayUrl, time, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 0b12bfbc1..b3ef5a1a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -108,18 +108,18 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") { if (followingEvents.isEmpty()) return null - return listOf( + return followingEvents.map { TypedFilter( // Metadata comes from any relay types = EVENT_FINDER_TYPES, filter = SincePerRelayFilter( kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to followingEvents.toList()), - limit = followingEvents.size * 2, + tags = mapOf("e" to listOf(it)), + limit = 1, ), - ), - ) + ) + } } fun createLastMessageOfEachChannelFilter(): List? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index 4d87321b7..16cbf010a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -90,7 +90,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ), @@ -111,7 +111,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -133,7 +133,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -153,7 +153,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ), @@ -179,7 +179,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ), @@ -194,7 +194,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -217,7 +217,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ), @@ -232,7 +232,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -255,7 +255,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -285,7 +285,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -315,7 +315,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -346,7 +346,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -377,7 +377,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -407,7 +407,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -437,7 +437,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultDiscoveryFollowList.value) + ?.get(account.settings.defaultDiscoveryFollowList.value) ?.relayList, ), ) @@ -447,7 +447,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") { requestNewChannel { time, relayUrl -> latestEOSEs.addOrUpdate( account.userProfile(), - account.defaultDiscoveryFollowList.value, + account.settings.defaultDiscoveryFollowList.value, relayUrl, time, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 3972f4a74..7d0daa2c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -46,7 +46,6 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { lateinit var account: Account @@ -61,8 +60,6 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { job?.cancel() job = scope.launch(Dispatchers.IO) { - // creates cache on main - withContext(Dispatchers.Main) { account.userProfile().live() } account.liveHomeFollowLists.collect { if (this@NostrHomeDataSource::account.isInitialized) { invalidateFilters() @@ -73,8 +70,6 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { job2?.cancel() job2 = scope.launch(Dispatchers.IO) { - // creates cache on main - withContext(Dispatchers.Main) { account.userProfile().live() } account.liveHomeListAuthorsPerRelay.collect { if (this@NostrHomeDataSource::account.isInitialized) { invalidateFilters() @@ -119,7 +114,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultHomeFollowList.value) + ?.get(account.settings.defaultHomeFollowList.value) ?.relayList, ), ) @@ -142,7 +137,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultHomeFollowList.value) + ?.get(account.settings.defaultHomeFollowList.value) ?.relayList, ), ) @@ -184,7 +179,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultHomeFollowList.value) + ?.get(account.settings.defaultHomeFollowList.value) ?.relayList, ), ) @@ -223,7 +218,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultHomeFollowList.value) + ?.get(account.settings.defaultHomeFollowList.value) ?.relayList, ), ) @@ -258,7 +253,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultHomeFollowList.value) + ?.get(account.settings.defaultHomeFollowList.value) ?.relayList, ), ) @@ -268,7 +263,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { requestNewChannel { time, relayUrl -> latestEOSEs.addOrUpdate( account.userProfile(), - account.defaultHomeFollowList.value, + account.settings.defaultHomeFollowList.value, relayUrl, time, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index df3ae87a9..2942ec92e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -78,7 +78,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultStoriesFollowList.value) + ?.get(account.settings.defaultStoriesFollowList.value) ?.relayList, ), ) @@ -109,7 +109,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultStoriesFollowList.value) + ?.get(account.settings.defaultStoriesFollowList.value) ?.relayList, ), ) @@ -140,7 +140,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { since = latestEOSEs.users[account.userProfile()] ?.followList - ?.get(account.defaultStoriesFollowList.value) + ?.get(account.settings.defaultStoriesFollowList.value) ?.relayList, ), ) @@ -150,7 +150,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") { requestNewChannel { time, relayUrl -> latestEOSEs.addOrUpdate( account.userProfile(), - account.defaultStoriesFollowList.value, + account.settings.defaultStoriesFollowList.value, relayUrl, time, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 57a728fa7..e8bbc2e77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -26,23 +26,23 @@ import android.util.Log import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.ChatMessageEvent -import com.vitorpamplona.quartz.events.ChatroomKey +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.SealedGossipEvent +import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.collections.immutable.persistentSetOf import java.math.BigDecimal class EventNotificationConsumer( @@ -73,28 +73,31 @@ class EventNotificationConsumer( private suspend fun consumeIfMatchesAccount( pushWrappedEvent: GiftWrapEvent, - account: Account, + account: AccountSettings, ) { - // no need to cache - pushWrappedEvent.unwrap(account.signer) { notificationEvent -> + // TODO: Modify the external launcher to launch as different users. + // Right now it only registers if Amber has already approved this signature + val signer = account.createSigner() + + pushWrappedEvent.unwrap(signer) { notificationEvent -> val consumed = LocalCache.hasConsumed(notificationEvent) val verified = LocalCache.justVerify(notificationEvent) - Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified") + Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified") if (!consumed && verified) { Log.d("EventNotificationConsumer", "New Notification was verified") - unwrapAndConsume(notificationEvent, account) { innerEvent -> + unwrapAndConsume(notificationEvent, signer) { innerEvent -> Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}") if (!consumed) { if (innerEvent is PrivateDmEvent) { Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify") - notify(innerEvent, account) + notify(innerEvent, signer, account) } else if (innerEvent is LnZapEvent) { Log.d("EventNotificationConsumer", "New Zap to Notify") - notify(innerEvent, account) + notify(innerEvent, signer, account) } else if (innerEvent is ChatMessageEvent) { Log.d("EventNotificationConsumer", "New ChatMessage to Notify") - notify(innerEvent, account) + notify(innerEvent, signer, account) } } } @@ -104,7 +107,7 @@ class EventNotificationConsumer( private fun unwrapAndConsume( event: Event, - account: Account, + signer: NostrSigner, onReady: (Event) -> Unit, ) { if (!LocalCache.justVerify(event)) return @@ -112,13 +115,13 @@ class EventNotificationConsumer( when (event) { is GiftWrapEvent -> { - event.unwrap(account.signer) { - unwrapAndConsume(it, account, onReady) + event.unwrap(signer) { + unwrapAndConsume(it, signer, onReady) LocalCache.justConsume(event, null) } } is SealedGossipEvent -> { - event.unseal(account.signer) { + event.unseal(signer) { if (!LocalCache.hasConsumed(it)) { // this is not verifiable LocalCache.justConsume(it, null) @@ -136,25 +139,25 @@ class EventNotificationConsumer( private fun notify( event: ChatMessageEvent, - acc: Account, + signer: NostrSigner, + acc: AccountSettings, ) { if ( event.createdAt > TimeUtils.fifteenMinutesAgo() && // old event being re-broadcasted - event.pubKey != acc.userProfile().pubkeyHex + event.pubKey != signer.pubKey ) { // from the user - + val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return val chatNote = LocalCache.getNoteIfExists(event.id) ?: return - val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey()) + val chatRoom = event.chatroomKey(signer.pubKey) - val followingKeySet = acc.followingKeySet() + val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return val isKnownRoom = ( - acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(chatRoom) - ) && - !acc.isAllHidden(chatRoom.users) + myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || + myUser.hasSentMessagesTo(chatRoom) + ) if (isKnownRoom) { val content = chatNote.event?.content() ?: "" @@ -177,32 +180,27 @@ class EventNotificationConsumer( private fun notify( event: PrivateDmEvent, - acc: Account, + signer: NostrSigner, + acc: AccountSettings, ) { val note = LocalCache.getNoteIfExists(event.id) ?: return + val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return // old event being re-broadcast if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return - if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { - val followingKeySet = acc.followingKeySet() + if (signer.pubKey == event.verifiedRecipientPubKey()) { + val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return - val knownChatrooms = - acc - .userProfile() - .privateChatrooms - .keys - .filter { - ( - acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || - acc.userProfile().hasSentMessagesTo(it) - ) && - !acc.isAllHidden(it.users) - }.toSet() + val chatRoom = event.chatroomKey(signer.pubKey) - note.author?.let { - if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) { - acc.decryptContent(note) { content -> + val isKnownRoom = + myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || + myUser.hasSentMessagesTo(chatRoom) + + if (isKnownRoom) { + note.author?.let { + decryptContent(note, signer) { content -> val user = note.author?.toBestDisplayName() ?: "" val userPicture = note.author?.profilePicture() val noteUri = note.toNEvent() @@ -214,9 +212,44 @@ class EventNotificationConsumer( } } + fun decryptZapContentAuthor( + note: Note, + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + val event = note.event + if (event is LnZapRequestEvent) { + if (event.isPrivateZap()) { + event.decryptPrivateZap(signer) { onReady(it) } + } else { + onReady(event) + } + } + } + + fun decryptContent( + note: Note, + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + val event = note.event + if (event is PrivateDmEvent) { + event.plainContent(signer, onReady) + } else if (event is LnZapRequestEvent) { + decryptZapContentAuthor(note, signer) { onReady(it.content) } + } else if (event is DraftEvent) { + event.cachedDraft(signer) { + onReady(it.content) + } + } else { + event?.content()?.let { onReady(it) } + } + } + private fun notify( event: LnZapEvent, - acc: Account, + signer: NostrSigner, + acc: AccountSettings, ) { Log.d("EventNotificationConsumer", "Notify Start ${event.toNostrUri()}") val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return @@ -238,20 +271,20 @@ class EventNotificationConsumer( Log.d("EventNotificationConsumer", "Notify Amount Bigger than 10") - if (event.isTaggedUser(acc.userProfile().pubkeyHex)) { + if (event.isTaggedUser(signer.pubKey)) { val amount = showAmount(event.amount) Log.d("EventNotificationConsumer", "Notify Amount $amount") (noteZapRequest.event as? LnZapRequestEvent)?.let { event -> - acc.decryptZapContentAuthor(noteZapRequest) { + decryptZapContentAuthor(noteZapRequest, signer) { Log.d("EventNotificationConsumer", "Notify Decrypted if Private Zap ${event.id}") val author = LocalCache.getOrCreateUser(it.pubKey) val senderInfo = Pair(author, it.content.ifBlank { null }) if (noteZapped.event?.content() != null) { - acc.decryptContent(noteZapped) { + decryptContent(noteZapped, signer) { Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note") val zappedContent = it.split("\n").get(0) @@ -275,7 +308,7 @@ class EventNotificationConsumer( zappedContent, ) } - val userPicture = senderInfo?.first?.profilePicture() + val userPicture = senderInfo.first.profilePicture() val noteUri = "nostr:Notifications" Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index a9d7a5954..90adc2ab1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -24,11 +24,8 @@ import android.util.Log import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.ammolite.service.HttpClientManager -import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.RelayAuthEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -55,7 +52,7 @@ class RegisterAccounts( private suspend fun signAllAuths( notificationToken: String, - remainingTos: List>>, + remainingTos: List>>, output: MutableList, onReady: (List) -> Unit, ) { @@ -71,7 +68,10 @@ class RegisterAccounts( val result = withTimeoutOrNull(10000) { suspendCancellableCoroutine { continuation -> - accountRelayPair.first.createAuthEvent(accountRelayPair.second, notificationToken) { result -> + val signer = accountRelayPair.first.createSigner() + // TODO: Modify the external launcher to launch as different users. + // Right now it only registers if Amber has already approved this signature + RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result -> continuation.resume(result) } } @@ -104,32 +104,18 @@ class RegisterAccounts( Log.d(tag, "Register Account ${it.npub}") val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) - if (acc != null && acc.isWriteable()) { - val nip65Read = - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex), - )?.event as? AdvertisedRelayListEvent - )?.readRelays() ?: acc.backupNIP65RelayList?.readRelays() ?: emptyList() + val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList() - Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP65 Reads ${nip65Read.joinToString(", ")}") + Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}") - val nip17Read = - ( - LocalCache - .getAddressableNoteIfExists( - ChatMessageRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex), - )?.event as? ChatMessageRelayListEvent - )?.relays() ?: acc.backupDMRelayList?.relays() ?: emptyList() + val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() - Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP17 Reads ${nip17Read.joinToString(", ")}") + Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}") - val kind3Relays = (acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()) - val readKind3Relays = kind3Relays?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList() + val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList() - Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} Kind3 Reads ${readKind3Relays.joinToString(", ")}") + Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}") val relays = (nip65Read + nip17Read + readKind3Relays) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 7a1f9dba3..fb6512608 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -329,11 +329,11 @@ fun EditPostView( ) { ImageVideoDescription( url, - accountViewModel.account.defaultFileServer, + accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent -> postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context) if (!server.isNip95) { - accountViewModel.account.changeDefaultFileServer(server.server) + accountViewModel.account.settings.changeDefaultFileServer(server.server) } }, onCancel = { postViewModel.contentToAddUrl = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 8aa79400e..818463a26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -333,7 +333,7 @@ open class NewMediaModel : ViewModel() { fun isVideo() = mediaType?.startsWith("video") - fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] + fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] fun onceUploaded(onceUploaded: () -> Unit) { this.onceUploaded = onceUploaded diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index a03baa8b1..55721be08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -164,7 +164,7 @@ fun NewMediaView( } postViewModel.selectedServer?.let { if (!it.isNip95) { - account.changeDefaultFileServer(it.server) + account.settings.changeDefaultFileServer(it.server) } } }, @@ -290,7 +290,7 @@ fun ImageVideoPost( label = stringRes(id = R.string.file_server), placeholder = fileServers - .firstOrNull { it.server == accountViewModel.account.defaultFileServer } + .firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer } ?.server ?.name ?: fileServers[0].server.name, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 0fda209b2..d34ae2481 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -491,11 +491,11 @@ fun NewPostView( ) { ImageVideoDescription( url, - accountViewModel.account.defaultFileServer, + accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent -> postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context) if (!server.isNip95) { - accountViewModel.account.changeDefaultFileServer(server.server) + accountViewModel.account.settings.changeDefaultFileServer(server.server) } }, onCancel = { postViewModel.contentToAddUrl = null }, @@ -1778,7 +1778,7 @@ fun ImageVideoDescription( label = stringRes(id = R.string.file_server), placeholder = fileServers - .firstOrNull { it.server == accountViewModel.account.defaultFileServer } + .firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer } ?.server ?.name ?: fileServers[0].server.name, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index acc5284dc..af776e756 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -184,7 +184,7 @@ class NewUserMetadataViewModel : ViewModel() { size = size, alt = null, sensitiveContent = null, - server = account.defaultFileServer, + server = account.settings.defaultFileServer, contentResolver = contentResolver, onProgress = {}, context = context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt index 67f268894..fd96ea9f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt @@ -98,7 +98,7 @@ class Kind3RelayListViewModel : ViewModel() { relayFile .map { val localInfoFeedTypes = - account.localRelays + account.settings.localRelays .filter { localRelay -> localRelay.url == it.key } .firstOrNull() ?.feedTypes @@ -119,7 +119,7 @@ class Kind3RelayListViewModel : ViewModel() { .sortedBy { it.relayStat.receivedBytes } .reversed() } else { - account.localRelays + account.settings.localRelays .map { Kind3BasicRelaySetupInfo( url = RelayUrlFormatter.normalize(it.url), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt index 803eb87b4..efde0c10d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.actions.relays class LocalRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.localRelayServers.toList() + override fun getRelayList(): List = account.settings.localRelayServers.toList() override fun saveRelayList(urlList: List) { - account.updateLocalRelayServers(urlList.toSet()) + account.settings.updateLocalRelayServers(urlList.toSet()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 0dfafb94e..c679106ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -97,7 +97,9 @@ fun SensitivityWarning( accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { - val accountState = accountViewModel.account.showSensitiveContent.collectAsStateWithLifecycle() + val accountState = + accountViewModel.account.settings.showSensitiveContent + .collectAsStateWithLifecycle() var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt index 92a4900d1..afba5e510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverChatFeedFilter.kt @@ -32,12 +32,12 @@ import com.vitorpamplona.quartz.events.PeopleListEvent open class DiscoverChatFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = - account.defaultDiscoveryFollowList.value == + account.settings.defaultDiscoveryFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultDiscoveryFollowList.value == + account.settings.defaultDiscoveryFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { @@ -67,7 +67,7 @@ open class DiscoverChatFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultDiscoveryFollowList.value, + selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt index 4465091d1..f5114ee9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverCommunityFeedFilter.kt @@ -32,19 +32,19 @@ import com.vitorpamplona.quartz.events.PeopleListEvent open class DiscoverCommunityFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = - account.defaultDiscoveryFollowList.value == + account.settings.defaultDiscoveryFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultDiscoveryFollowList.value == + account.settings.defaultDiscoveryFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val filterParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultDiscoveryFollowList.value, + selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) @@ -73,7 +73,7 @@ open class DiscoverCommunityFeedFilter( val filterParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultDiscoveryFollowList.value, + selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt index abb993745..67d38a0ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverLiveFeedFilter.kt @@ -36,7 +36,7 @@ open class DiscoverLiveFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() - open fun followList(): String = account.defaultDiscoveryFollowList.value + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || @@ -57,7 +57,7 @@ open class DiscoverLiveFeedFilter( val filterParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultDiscoveryFollowList.value, + selectedListName = account.settings.defaultDiscoveryFollowList.value, followLists = account.liveDiscoveryFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt index 8d96f4b83..69c2083c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt @@ -32,7 +32,7 @@ open class DiscoverMarketplaceFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() - open fun followList(): String = account.defaultDiscoveryFollowList.value + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || @@ -55,7 +55,7 @@ open class DiscoverMarketplaceFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( account.userProfile().pubkeyHex, - account.defaultDiscoveryFollowList.value, + account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt index abb509d0f..ca32d18d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverNIP89FeedFilter.kt @@ -36,7 +36,7 @@ open class DiscoverNIP89FeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() - open fun followList(): String = account.defaultDiscoveryFollowList.value + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || @@ -58,7 +58,7 @@ open class DiscoverNIP89FeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( account.userProfile().pubkeyHex, - account.defaultDiscoveryFollowList.value, + account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index c4a46c40e..d5e737ac2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -33,11 +33,11 @@ import com.vitorpamplona.quartz.events.TextNoteEvent class HomeConversationsFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value override fun showHiddenKey(): Boolean = - account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val filterParams = buildFilterParams(account) @@ -54,7 +54,7 @@ class HomeConversationsFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultHomeFollowList.value, + selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index f27b166ac..f650584fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -39,16 +39,16 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent class HomeNewThreadFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value override fun showHiddenKey(): Boolean = - account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultHomeFollowList.value, + selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt index 3349fe331..f1a3c975d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -37,7 +37,7 @@ open class NIP90ContentDiscoveryResponseFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + request - open fun followList(): String = account.defaultDiscoveryFollowList.value + open fun followList(): String = account.settings.defaultDiscoveryFollowList.value override fun showHiddenKey(): Boolean = followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || @@ -71,7 +71,7 @@ open class NIP90ContentDiscoveryResponseFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( account.userProfile().pubkeyHex, - account.defaultDiscoveryFollowList.value, + account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 97582dd6a..e9b6facaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -47,18 +47,18 @@ import com.vitorpamplona.quartz.events.RepostEvent class NotificationFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultNotificationFollowList.value override fun showHiddenKey(): Boolean = - account.defaultNotificationFollowList.value == + account.settings.defaultNotificationFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultNotificationFollowList.value == + account.settings.defaultNotificationFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultNotificationFollowList.value, + selectedListName = account.settings.defaultNotificationFollowList.value, followLists = account.liveNotificationFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt index 9a595f277..34693e20f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileGalleryFeedFilter.kt @@ -35,8 +35,8 @@ class UserProfileGalleryFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + "ProfileGallery" override fun showHiddenKey(): Boolean = - account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val params = buildFilterParams(account) @@ -74,7 +74,7 @@ class UserProfileGalleryFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultStoriesFollowList.value, + selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index 20629b0d6..bd3042649 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -34,11 +34,11 @@ import com.vitorpamplona.quartz.events.VideoVerticalEvent class VideoFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value override fun showHiddenKey(): Boolean = - account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { val params = buildFilterParams(account) @@ -78,7 +78,7 @@ class VideoFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( userHex = account.userProfile().pubkeyHex, - selectedListName = account.defaultStoriesFollowList.value, + selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, hiddenUsers = account.flowHiddenUsers.value, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 7df7d0618..c5c7462cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -87,7 +88,12 @@ fun AccountSwitchBottomSheet( accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, ) { - val accounts = LocalPreferences.allSavedAccounts() + val accounts by + produceState(initialValue = LocalPreferences.cachedAccounts()) { + if (value == null) { + value = LocalPreferences.allSavedAccounts() + } + } var popupExpanded by remember { mutableStateOf(false) } val scrollState = rememberScrollState() @@ -103,7 +109,7 @@ fun AccountSwitchBottomSheet( ) { Text(stringRes(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) } - accounts.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) } + accounts?.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) } Row( modifier = Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 7a5ae598a..56037609d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -448,13 +448,14 @@ fun StoriesTopBar( nav: (String) -> Unit, ) { GenericMainTopBar(openDrawer, accountViewModel, nav) { - val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle() + val list by accountViewModel.account.settings.defaultStoriesFollowList + .collectAsStateWithLifecycle() FollowListWithRoutes( followListsModel = followLists, listName = list, ) { listName -> - accountViewModel.account.changeDefaultStoriesFollowList(listName.code) + accountViewModel.account.settings.changeDefaultStoriesFollowList(listName.code) } } } @@ -467,7 +468,8 @@ fun HomeTopBar( nav: (String) -> Unit, ) { GenericMainTopBar(openDrawer, accountViewModel, nav) { - val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle() + val list by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() FollowListWithRoutes( followListsModel = followLists, @@ -476,7 +478,7 @@ fun HomeTopBar( if (listName.type == CodeNameType.ROUTE) { nav(listName.code) } else { - accountViewModel.account.changeDefaultHomeFollowList(listName.code) + accountViewModel.account.settings.changeDefaultHomeFollowList(listName.code) } } } @@ -490,13 +492,14 @@ fun NotificationTopBar( nav: (String) -> Unit, ) { GenericMainTopBar(openDrawer, accountViewModel, nav) { - val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle() + val list by accountViewModel.account.settings.defaultNotificationFollowList + .collectAsStateWithLifecycle() FollowListWithoutRoutes( followListsModel = followLists, listName = list, ) { listName -> - accountViewModel.account.changeDefaultNotificationFollowList(listName.code) + accountViewModel.account.settings.changeDefaultNotificationFollowList(listName.code) } } } @@ -509,13 +512,14 @@ fun DiscoveryTopBar( nav: (String) -> Unit, ) { GenericMainTopBar(openDrawer, accountViewModel, nav) { - val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle() + val list by accountViewModel.account.settings.defaultDiscoveryFollowList + .collectAsStateWithLifecycle() FollowListWithoutRoutes( followListsModel = followLists, listName = list, ) { listName -> - accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code) + accountViewModel.account.settings.changeDefaultDiscoveryFollowList(listName.code) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index fbd2e364c..8cf4cc3f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -446,10 +446,17 @@ fun ListContent( var editMediaServers by remember { mutableStateOf(false) } var backupDialogOpen by remember { mutableStateOf(false) } - var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) } + var checked by remember { mutableStateOf(accountViewModel.account.settings.proxy != null) } var disconnectTorDialog by remember { mutableStateOf(false) } var conectOrbotDialogOpen by remember { mutableStateOf(false) } - val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) } + val proxyPort = + remember { + mutableStateOf( + accountViewModel.account.settings.proxyPort + .toString(), + ) + } + val context = LocalContext.current Column(modifier) { @@ -507,7 +514,7 @@ fun ListContent( route = Route.BlockedUsers.route, ) - accountViewModel.account.keyPair.privKey?.let { + accountViewModel.account.settings.keyPair.privKey?.let { IconRow( title = stringRes(R.string.backup_keys), icon = R.drawable.ic_key, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 00a162abd..585c9c815 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -181,7 +181,8 @@ fun LoadOts( (earliestDate as? GenericLoadable.Loaded)?.let { whenConfirmed(it.loaded) } ?: run { - val pendingAttestations by accountViewModel.account.pendingAttestations.collectAsStateWithLifecycle() + val pendingAttestations by accountViewModel.account.settings.pendingAttestations + .collectAsStateWithLifecycle() val id = note.event?.id() ?: note.idHex if (pendingAttestations[id] != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index fa0f979b8..477220bf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -367,7 +367,7 @@ private fun RenderMainPopup( Icons.Default.Block, stringRes(R.string.quick_action_block), ) { - if (accountViewModel.hideBlockAlertDialog) { + if (accountViewModel.account.settings.hideBlockAlertDialog) { note.author?.let { accountViewModel.hide(it) } onDismiss() } else { @@ -385,7 +385,7 @@ private fun RenderMainPopup( Icons.Default.Delete, stringRes(R.string.quick_action_delete), ) { - if (accountViewModel.hideDeleteRequestDialog) { + if (accountViewModel.account.settings.hideDeleteRequestDialog) { accountViewModel.delete(note) onDismiss() } else { @@ -560,7 +560,7 @@ private fun RenderDeleteFromGalleryPopup( Icons.Default.Delete, stringRes(R.string.quick_action_delete), ) { - if (accountViewModel.hideDeleteRequestDialog) { + if (accountViewModel.account.settings.hideDeleteRequestDialog) { accountViewModel.delete(note) onDismiss() } else { @@ -699,7 +699,7 @@ fun DeleteAlertDialog( }, onClickDontShowAgain = { accountViewModel.delete(note) - accountViewModel.dontShowDeleteRequestDialog() + accountViewModel.account.settings.setHideDeleteRequestDialog() onDismiss() }, onDismiss = onDismiss, @@ -727,7 +727,7 @@ private fun BlockAlertDialog( }, onClickDontShowAgain = { note.author?.let { accountViewModel.hide(it) } - accountViewModel.dontShowBlockAlertDialog() + accountViewModel.account.settings.setHideBlockAlertDialog() onDismiss() }, onDismiss = onDismiss, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 0b0566d19..9ff435877 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -380,14 +381,16 @@ fun ZapVote( ) return@combinedClickable } else if ( - accountViewModel.account.zapAmountChoices.size == 1 && + accountViewModel.account.settings.zapAmountChoices.value.size == 1 && pollViewModel.isValidInputVoteAmount( - accountViewModel.account.zapAmountChoices.first(), + accountViewModel.account.settings.zapAmountChoices.value + .first(), ) ) { accountViewModel.zap( baseNote, - accountViewModel.account.zapAmountChoices.first() * 1000, + accountViewModel.account.settings.zapAmountChoices.value + .first() * 1000, poolOption.option, "", context, @@ -519,12 +522,14 @@ fun FilteredZapAmountChoicePopup( ) { val context = LocalContext.current - val accountState by accountViewModel.accountLiveData.observeAsState() + // TODO: Move this to the viewModel + val zapPaymentChoices by accountViewModel.account.settings.zapAmountChoices + .collectAsStateWithLifecycle() val zapMessage = "" val sortedOptions = - remember(accountState) { pollViewModel.createZapOptionsThatMatchThePollingParameters() } + remember(zapPaymentChoices) { pollViewModel.createZapOptionsThatMatchThePollingParameters(zapPaymentChoices) } Popup( alignment = Alignment.BottomCenter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 33405a42c..8ec1f5999 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -262,10 +262,11 @@ class PollNoteViewModel : ViewModel() { } ?: BigDecimal.ZERO - fun createZapOptionsThatMatchThePollingParameters(): List { + fun createZapOptionsThatMatchThePollingParameters(zapPaymentChoices: List): List { val options = - account?.zapAmountChoices?.filter { isValidInputVoteAmount(it) }?.toMutableList() - ?: mutableListOf() + zapPaymentChoices + .filter { isValidInputVoteAmount(it) } + .toMutableList() if (options.isEmpty()) { valueMinimum?.let { minimum -> valueMaximum?.let { maximum -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 80809c699..592369453 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -93,6 +93,7 @@ import androidx.compose.ui.window.Popup import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.vitorpamplona.amethyst.R @@ -954,7 +955,9 @@ private fun likeClick( ) return } - if (accountViewModel.account.reactionChoices.isEmpty()) { + if (accountViewModel.account.settings.reactionChoices.value + .isEmpty() + ) { accountViewModel.toast( R.string.no_reactions_setup, R.string.no_reaction_type_setup_long_press_to_change, @@ -964,9 +967,9 @@ private fun likeClick( R.string.read_only_user, R.string.login_with_a_private_key_to_like_posts, ) - } else if (accountViewModel.account.reactionChoices.size == 1) { + } else if (accountViewModel.account.settings.reactionChoices.value.size == 1) { onWantsToSignReaction() - } else if (accountViewModel.account.reactionChoices.size > 1) { + } else if (accountViewModel.account.settings.reactionChoices.value.size > 1) { onMultipleChoices() } } @@ -1028,7 +1031,6 @@ fun ZapReaction( if (wantsToZap) { ZapAmountChoicePopup( baseNote = baseNote, - zapAmountChoices = accountViewModel.account.zapAmountChoices, popupYOffset = iconSize, accountViewModel = accountViewModel, onDismiss = { @@ -1161,7 +1163,9 @@ fun zapClick( return } - if (accountViewModel.account.zapAmountChoices.isEmpty()) { + if (accountViewModel.account.settings.zapAmountChoices.value + .isEmpty() + ) { accountViewModel.toast( R.string.error_dialog_zap_error, R.string.no_zap_amount_setup_long_press_to_change, @@ -1171,10 +1175,11 @@ fun zapClick( R.string.error_dialog_zap_error, R.string.login_with_a_private_key_to_be_able_to_send_zaps, ) - } else if (accountViewModel.account.zapAmountChoices.size == 1) { + } else if (accountViewModel.account.settings.zapAmountChoices.value.size == 1) { accountViewModel.zap( baseNote, - accountViewModel.account.zapAmountChoices.first() * 1000, + accountViewModel.account.settings.zapAmountChoices.value + .first() * 1000, null, "", context, @@ -1182,7 +1187,7 @@ fun zapClick( onProgress = { onZappingProgress(it) }, onPayViaIntent = onPayViaIntent, ) - } else if (accountViewModel.account.zapAmountChoices.size > 1) { + } else if (accountViewModel.account.settings.zapAmountChoices.value.size > 1) { onMultipleChoices() } } @@ -1312,21 +1317,19 @@ fun ReactionChoicePopup( onDismiss: () -> Unit, onChangeAmount: () -> Unit, ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() } - val reactions = remember { account.reactionChoices.toImmutableList() } - val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } + val reactions by accountViewModel.account.settings.reactionChoices + .collectAsStateWithLifecycle() + val toRemove = remember { baseNote.reactedBy(accountViewModel.userProfile()).toImmutableSet() } + Popup( alignment = Alignment.BottomCenter, offset = IntOffset(0, iconSizePx), onDismissRequest = { onDismiss() }, ) { ReactionChoicePopupContent( - reactions, + reactions.toImmutableList(), toRemove = toRemove, onClick = { reactionType -> accountViewModel.reactToOrDelete( @@ -1462,6 +1465,24 @@ fun RenderReaction(reactionType: String) { } } +@Composable +fun ZapAmountChoicePopup( + baseNote: Note, + accountViewModel: AccountViewModel, + popupYOffset: Dp, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, + onError: (title: String, text: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, +) { + val zapAmountChoices by + accountViewModel.account.settings.zapAmountChoices + .collectAsStateWithLifecycle() + + ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent) +} + @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun ZapAmountChoicePopup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 27df7ae23..f4f1a22a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -75,7 +74,7 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.firstFullChar import com.vitorpamplona.amethyst.ui.actions.CloseButton @@ -97,13 +96,13 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch class UpdateReactionTypeViewModel( - val account: Account, + val accountSettings: AccountSettings, ) : ViewModel() { var nextChoice by mutableStateOf(TextFieldValue("")) var reactionSet by mutableStateOf(listOf()) fun load() { - this.reactionSet = account.reactionChoices + this.reactionSet = accountSettings.reactionChoices.value } fun toListOfChoices(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } @@ -124,7 +123,7 @@ class UpdateReactionTypeViewModel( } fun sendPost() { - account.changeReactionTypes(reactionSet) + accountSettings.changeReactionTypes(reactionSet) nextChoice = TextFieldValue("") } @@ -132,12 +131,12 @@ class UpdateReactionTypeViewModel( nextChoice = TextFieldValue("") } - fun hasChanged(): Boolean = reactionSet != account.reactionChoices + fun hasChanged(): Boolean = reactionSet != accountSettings.reactionChoices.value class Factory( - val account: Account, + val accountSettings: AccountSettings, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UpdateReactionTypeViewModel = UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel + override fun create(modelClass: Class): UpdateReactionTypeViewModel = UpdateReactionTypeViewModel(accountSettings) as UpdateReactionTypeViewModel } } @@ -151,7 +150,7 @@ fun UpdateReactionTypeDialog( val postViewModel: UpdateReactionTypeViewModel = viewModel( key = "UpdateReactionTypeViewModel", - factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account), + factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account.settings), ) LaunchedEffect(accountViewModel) { postViewModel.load() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index f7b83adeb..20c737113 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -47,7 +47,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.outlined.ContentPaste import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff @@ -87,7 +86,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner @@ -110,7 +109,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException class UpdateZapAmountViewModel( - val account: Account, + val accountSettings: AccountSettings, ) : ViewModel() { var nextAmount by mutableStateOf(TextFieldValue("")) var amountSet by mutableStateOf(listOf()) @@ -127,14 +126,14 @@ class UpdateZapAmountViewModel( } fun load() { - this.amountSet = account.zapAmountChoices + this.amountSet = accountSettings.zapAmountChoices.value this.walletConnectPubkey = - account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") + accountSettings.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = - account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") + accountSettings.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectSecret = - account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.selectedZapType = account.defaultZapType.value + accountSettings.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.selectedZapType = accountSettings.defaultZapType.value } fun toListOfAmounts(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } @@ -153,8 +152,8 @@ class UpdateZapAmountViewModel( } fun sendPost() { - account?.changeZapAmounts(amountSet) - account?.changeDefaultZapType(selectedZapType) + accountSettings.changeZapAmounts(amountSet) + accountSettings.changeDefaultZapType(selectedZapType) if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { val pubkeyHex = @@ -173,7 +172,7 @@ class UpdateZapAmountViewModel( val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } if (pubkeyHex != null) { - account.changeZapPaymentRequest( + accountSettings.changeZapPaymentRequest( Nip47WalletConnect.Nip47URI( pubkeyHex, relayUrl, @@ -181,10 +180,10 @@ class UpdateZapAmountViewModel( ), ) } else { - account?.changeZapPaymentRequest(null) + accountSettings.changeZapPaymentRequest(null) } } else { - account?.changeZapPaymentRequest(null) + accountSettings.changeZapPaymentRequest(null) } nextAmount = TextFieldValue("") @@ -196,11 +195,11 @@ class UpdateZapAmountViewModel( fun hasChanged(): Boolean = ( - selectedZapType != account?.defaultZapType?.value || - amountSet != account?.zapAmountChoices || - walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || - walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || - walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "") + selectedZapType != accountSettings.defaultZapType.value || + amountSet != accountSettings.zapAmountChoices.value || + walletConnectPubkey.text != (accountSettings.zapPaymentRequest?.pubKeyHex ?: "") || + walletConnectRelay.text != (accountSettings.zapPaymentRequest?.relayUri ?: "") || + walletConnectSecret.text != (accountSettings.zapPaymentRequest?.secret ?: "") ) fun updateNIP47(uri: String) { @@ -213,9 +212,9 @@ class UpdateZapAmountViewModel( } class Factory( - val account: Account, + val accountSettings: AccountSettings, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UpdateZapAmountViewModel = UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel + override fun create(modelClass: Class): UpdateZapAmountViewModel = UpdateZapAmountViewModel(accountSettings) as UpdateZapAmountViewModel } } @@ -232,7 +231,7 @@ fun UpdateZapAmountDialog( val postViewModel: UpdateZapAmountViewModel = viewModel( key = "UpdateZapAmountViewModel", - factory = UpdateZapAmountViewModel.Factory(accountViewModel.account), + factory = UpdateZapAmountViewModel.Factory(accountViewModel.account.settings), ) val uri = LocalUriHandler.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 9a60fb236..4f7fff0fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -153,7 +153,7 @@ fun ZapCustomDialog( } var selectedZapType by - remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType.value) } + remember(accountViewModel) { mutableStateOf(accountViewModel.account.settings.defaultZapType.value) } Dialog( onDismissRequest = { onClose() }, @@ -224,7 +224,7 @@ fun ZapCustomDialog( label = stringRes(id = R.string.zap_type), placeholder = zapTypes - .filter { it.first == accountViewModel.account.defaultZapType.value } + .filter { it.first == accountViewModel.account.settings.defaultZapType.value } .first() .second, options = zapOptions, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index 5d12dabfa..eac4d864c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -38,59 +38,26 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.ui.actions.relays.AddDMRelayListDialog import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BigPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent -import fr.acinq.secp256k1.Hex -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob @Preview @Composable fun AddInboxRelayForDMCardPreview() { - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - val myCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - sharedPreferencesViewModel.init() - sharedPreferencesViewModel.updateTheme(ThemeType.DARK) - - val pubkey = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799" - - val myAccount = - Account( - keyPair = - KeyPair( - privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"), - pubKey = Hex.decode(pubkey), - forcePubKeyCheck = false, - ), - scope = myCoroutineScope, - ) - - val accountViewModel = - AccountViewModel( - myAccount, - sharedPreferencesViewModel.sharedPrefs, - ) - ThemeComparisonColumn { AddInboxRelayForDMCard( - accountViewModel = accountViewModel, + accountViewModel = mockAccountViewModel(), nav = {}, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt index 42edc49eb..c54cc2ef9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForSearchCard.kt @@ -38,59 +38,26 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.ui.actions.relays.AddSearchRelayListDialog import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BigPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.SearchRelayListEvent -import fr.acinq.secp256k1.Hex -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob @Preview @Composable fun AddInboxRelayForSearchCardPreview() { - val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() - val myCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - sharedPreferencesViewModel.init() - sharedPreferencesViewModel.updateTheme(ThemeType.DARK) - - val pubkey = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799" - - val myAccount = - Account( - keyPair = - KeyPair( - privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"), - pubKey = Hex.decode(pubkey), - forcePubKeyCheck = false, - ), - scope = myCoroutineScope, - ) - - val accountViewModel = - AccountViewModel( - myAccount, - sharedPreferencesViewModel.sharedPrefs, - ) - ThemeComparisonColumn { AddInboxRelayForSearchCard( - accountViewModel = accountViewModel, + accountViewModel = mockAccountViewModel(), nav = {}, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 03b4d1eb6..78cfd1fb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView @@ -396,10 +397,8 @@ fun WatchBookmarksFollowsAndAccount( .live() .bookmarks .observeAsState() - val showSensitiveContent by - accountViewModel.showSensitiveContentChanges.observeAsState( - accountViewModel.account.showSensitiveContent, - ) + val showSensitiveContent by accountViewModel.account.settings.showSensitiveContent + .collectAsStateWithLifecycle() LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { launch(Dispatchers.IO) { @@ -411,7 +410,7 @@ fun WatchBookmarksFollowsAndAccount( isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitive() ?: false, - showSensitiveContent = showSensitiveContent.value, + showSensitiveContent = showSensitiveContent, ) launch(Dispatchers.Main) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index b66d3e735..d3e3b4622 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -326,7 +326,6 @@ fun ZapDonationButton( if (wantsToZap != null) { ZapAmountChoicePopup( baseNote = baseNote, - zapAmountChoices = wantsToZap ?: accountViewModel.account.zapAmountChoices, popupYOffset = iconSize, accountViewModel = accountViewModel, onDismiss = { @@ -452,7 +451,9 @@ fun customZapClick( return } - if (accountViewModel.account.zapAmountChoices.isEmpty()) { + if (accountViewModel.account.settings.zapAmountChoices.value + .isEmpty() + ) { accountViewModel.toast( stringRes(context, R.string.error_dialog_zap_error), stringRes(context, R.string.no_zap_amount_setup_long_press_to_change), @@ -462,8 +463,10 @@ fun customZapClick( stringRes(context, R.string.error_dialog_zap_error), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_send_zaps), ) - } else if (accountViewModel.account.zapAmountChoices.size == 1) { - val amount = accountViewModel.account.zapAmountChoices.first() + } else if (accountViewModel.account.settings.zapAmountChoices.value.size == 1) { + val amount = + accountViewModel.account.settings.zapAmountChoices.value + .first() if (amount > 1100) { accountViewModel.zap( @@ -481,9 +484,11 @@ fun customZapClick( onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) // recommends amounts for a monthly release. } - } else if (accountViewModel.account.zapAmountChoices.size > 1) { - if (accountViewModel.account.zapAmountChoices.any { it > 1100 }) { - onMultipleChoices(accountViewModel.account.zapAmountChoices) + } else if (accountViewModel.account.settings.zapAmountChoices.value.size > 1) { + if (accountViewModel.account.settings.zapAmountChoices.value + .any { it > 1100 } + ) { + onMultipleChoices(accountViewModel.account.settings.zapAmountChoices.value) } else { onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index fd71836cb..18d367de4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,7 +51,7 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -59,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.signers.NostrSignerExternal import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch @Composable fun AccountScreen( @@ -95,7 +97,7 @@ fun AccountScreen( LocalViewModelStoreOwner provides state.currentViewModelStore, ) { LoggedInPage( - state.account, + state.accountSettings, accountStateViewModel, sharedPreferencesViewModel, ) @@ -106,7 +108,7 @@ fun AccountScreen( LocalViewModelStoreOwner provides state.currentViewModelStore, ) { LoggedInPage( - state.account, + state.accountSettings, accountStateViewModel, sharedPreferencesViewModel, ) @@ -118,7 +120,7 @@ fun AccountScreen( @Composable fun LoggedInPage( - account: Account, + accountSettings: AccountSettings, accountStateViewModel: AccountStateViewModel, sharedPreferencesViewModel: SharedPreferencesViewModel, ) { @@ -127,11 +129,15 @@ fun LoggedInPage( key = "AccountViewModel", factory = AccountViewModel.Factory( - account, + accountSettings, sharedPreferencesViewModel.sharedPrefs, ), ) + LaunchedEffect(key1 = accountViewModel) { + accountViewModel.restartServices() + } + val activity = getActivity() as MainActivity if (accountViewModel.account.signer is NostrSignerExternal) { @@ -208,7 +214,7 @@ fun LoggedInPage( } class AccountCentricViewModelStore( - val account: Account, + val accountSettings: AccountSettings, ) : ViewModelStoreOwner { override val viewModelStore = ViewModelStore() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index af7f2d5b4..105630729 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings sealed class AccountState { object Loading : AccountState() @@ -28,14 +28,14 @@ sealed class AccountState { object LoggedOff : AccountState() class LoggedInViewOnly( - val account: Account, + val accountSettings: AccountSettings, ) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(account) + val currentViewModelStore = AccountCentricViewModelStore(accountSettings) } class LoggedIn( - val account: Account, + val accountSettings: AccountSettings, ) : AccountState() { - val currentViewModelStore = AccountCentricViewModelStore(account) + val currentViewModelStore = AccountCentricViewModelStore(accountSettings) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 0c9c3ec1b..1fb58e771 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -27,9 +27,13 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.DefaultChannels +import com.vitorpamplona.amethyst.model.DefaultDMRelayList +import com.vitorpamplona.amethyst.model.DefaultNIP65List +import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier -import com.vitorpamplona.ammolite.relays.Client +import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair @@ -39,16 +43,20 @@ import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toNpub -import com.vitorpamplona.quartz.signers.ExternalSignerLauncher -import com.vitorpamplona.quartz.signers.NostrSignerExternal -import com.vitorpamplona.quartz.signers.NostrSignerInternal +import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.events.Contact +import com.vitorpamplona.quartz.events.ContactListEvent +import com.vitorpamplona.quartz.events.MetadataEvent +import com.vitorpamplona.quartz.events.SearchRelayListEvent +import com.vitorpamplona.quartz.signers.NostrSignerSync import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -63,15 +71,17 @@ class AccountStateViewModel : ViewModel() { private val _accountContent = MutableStateFlow(AccountState.Loading) val accountContent = _accountContent.asStateFlow() + private var collectorJob: Job? = null + fun tryLoginExistingAccountAsync() { // pulls account from storage. - viewModelScope.launch(Dispatchers.IO) { tryLoginExistingAccount() } + viewModelScope.launch { tryLoginExistingAccount() } } private suspend fun tryLoginExistingAccount() = withContext(Dispatchers.IO) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } ?: run { requestLoginUI() } - } + LocalPreferences.loadCurrentAccountFromEncryptedStorage() + }?.let { startUI(it) } ?: run { requestLoginUI() } private suspend fun requestLoginUI() { _accountContent.update { AccountState.LoggedOff } @@ -108,49 +118,35 @@ class AccountStateViewModel : ViewModel() { val account = if (loginWithExternalSigner) { - val keyPair = KeyPair(pubKey = pubKeyParsed) - val localPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" } - Account( - keyPair, + AccountSettings( + keyPair = KeyPair(pubKey = pubKeyParsed), + externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }, proxy = proxy, proxyPort = proxyPort, - signer = - NostrSignerExternal( - keyPair.pubKey.toHexKey(), - ExternalSignerLauncher(keyPair.pubKey.toNpub(), localPackageName), - ), ) } else if (key.startsWith("nsec")) { - val keyPair = KeyPair(privKey = key.bechToBytes()) - Account( - keyPair, + AccountSettings( + keyPair = KeyPair(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), ) } else if (key.contains(" ") && CryptoUtils.isValidMnemonic(key)) { - val keyPair = KeyPair(privKey = CryptoUtils.privateKeyFromMnemonic(key)) - Account( - keyPair, + AccountSettings( + keyPair = KeyPair(privKey = CryptoUtils.privateKeyFromMnemonic(key)), proxy = proxy, proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), ) } else if (pubKeyParsed != null) { - val keyPair = KeyPair(pubKey = pubKeyParsed) - Account( - keyPair, + AccountSettings( + keyPair = KeyPair(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), ) } else { - val keyPair = KeyPair(Hex.decode(key)) - Account( - keyPair, + AccountSettings( + keyPair = KeyPair(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), ) } @@ -159,51 +155,37 @@ class AccountStateViewModel : ViewModel() { startUI(account) } - suspend fun startUI( - account: Account, - onServicesReady: (() -> Unit)? = null, - ) = withContext(Dispatchers.Main) { - if (account.isWriteable()) { - _accountContent.update { AccountState.LoggedIn(account) } - } else { - _accountContent.update { AccountState.LoggedInViewOnly(account) } - } - - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - // Prepares livedata objects on the main user. - account.userProfile().live() - } - serviceManager?.restartIfDifferentAccount(account) - - if (onServicesReady != null) { - // waits for the connection to go through - delay(1000) - onServicesReady() - } - } - - account.saveable.observeForever(saveListener) - } - - @OptIn(DelicateCoroutinesApi::class) - private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { - GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) } - } - - private suspend fun prepareLogoutOrSwitch() = + @OptIn(FlowPreview::class) + suspend fun startUI(accountSettings: AccountSettings) = withContext(Dispatchers.Main) { - when (val state = _accountContent.value) { - is AccountState.LoggedIn -> { - state.account.saveable.removeObserver(saveListener) - withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } - } - is AccountState.LoggedInViewOnly -> { - state.account.saveable.removeObserver(saveListener) - withContext(Dispatchers.IO) { state.currentViewModelStore.viewModelStore.clear() } - } - else -> {} + if (accountSettings.isWriteable()) { + _accountContent.update { AccountState.LoggedIn(accountSettings) } + } else { + _accountContent.update { AccountState.LoggedInViewOnly(accountSettings) } } + + collectorJob?.cancel() + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + accountSettings.saveable.debounce(1000).collect { + LocalPreferences.saveToEncryptedStorage(it.accountSettings) + } + } + } + + private fun prepareLogoutOrSwitch() = + when (val state = _accountContent.value) { + is AccountState.LoggedIn -> { + collectorJob?.cancel() + state.currentViewModelStore.viewModelStore.clear() + } + + is AccountState.LoggedInViewOnly -> { + collectorJob?.cancel() + state.currentViewModelStore.viewModelStore.clear() + } + + else -> {} } fun login( @@ -290,24 +272,34 @@ class AccountStateViewModel : ViewModel() { name: String? = null, ) { viewModelScope.launch(Dispatchers.IO) { - val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort) val keyPair = KeyPair() - val account = - Account( - keyPair, - proxy = proxy, + val tempSigner = NostrSignerSync(keyPair) + + val accountSettings = + AccountSettings( + keyPair = keyPair, + backupUserMetadata = MetadataEvent.newUser(name, tempSigner), + backupContactList = + ContactListEvent.createFromScratch( + followUsers = listOf(Contact(keyPair.pubKey.toHexKey(), null)), + followEvents = DefaultChannels.toList(), + relayUse = + Constants.defaultRelays.associate { + it.url to ContactListEvent.ReadWrite(it.read, it.write) + }, + signer = tempSigner, + ), + backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), + backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), + backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList, tempSigner), + proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort), proxyPort = proxyPort, - signer = NostrSignerInternal(keyPair), ) - account.follow(account.userProfile()) - // saves to local preferences - LocalPreferences.updatePrefsForLogin(account) - startUI(account) { - account.userProfile().latestContactList?.let { Client.send(it) } - account.sendNewUserMetadata(name = name) - } + LocalPreferences.updatePrefsForLogin(accountSettings) + + startUI(accountSettings) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 8021d6965..cfa01bf0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -368,7 +368,7 @@ private fun copyNSec( account: Account, clipboardManager: ClipboardManager, ) { - account.keyPair.privKey?.let { + account.settings.keyPair.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) scope.launch { Toast @@ -398,7 +398,7 @@ private fun encryptCopyNSec( ).show() } } else { - accountViewModel.account.keyPair.privKey?.let { + accountViewModel.account.settings.keyPair.privKey?.let { val key = CryptoUtils.encryptNIP49(it.toHexKey(), password.value.text) if (key != null) { clipboardManager.setText(AnnotatedString(key)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 06456d731..6e3b124e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -28,10 +28,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel @@ -42,7 +40,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.AccountState +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache @@ -73,14 +71,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.relays.BundledInsert -import com.vitorpamplona.ammolite.service.HttpClientManager import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.Nip11RelayInformation import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.RelayUrlFormatter -import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent @@ -107,10 +103,8 @@ import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.coroutineScope @@ -127,7 +121,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import java.util.Locale import kotlin.coroutines.resume import kotlin.time.measureTimedValue @@ -152,11 +145,11 @@ import kotlin.time.measureTimedValue @Stable class AccountViewModel( - val account: Account, + accountSettings: AccountSettings, val settings: SettingsState, ) : ViewModel(), Dao { - val accountLiveData: LiveData = account.live.map { it } + val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) // TODO: contact lists are not notes yet // val kind3Relays: StateFlow = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey) @@ -185,9 +178,6 @@ class AccountViewModel( val feedStates = AccountFeedContentStates(this) - val showSensitiveContentChanges = - account.live.map { it.account.showSensitiveContent }.distinctUntilChanged() - fun clearToasts() { viewModelScope.launch { toasts.emit(null) } } @@ -259,7 +249,9 @@ class AccountViewModel( fun reactToOrDelete(note: Note) { viewModelScope.launch(Dispatchers.IO) { - val reaction = account.reactionChoices.first() + val reaction = + account.settings.reactionChoices.value + .first() if (hasReactedTo(note, reaction)) { deleteReactionTo(note, reaction) } else { @@ -641,7 +633,7 @@ class AccountViewModel( onProgress(it) }, onPayViaIntent = onPayViaIntent, - zapType = zapType ?: account.defaultZapType.value, + zapType = zapType ?: account.settings.defaultZapType.value, ) } } @@ -768,22 +760,6 @@ class AccountViewModel( account.decryptZapContentAuthor(note, onReady) } - fun translateTo(lang: Locale) { - account.updateTranslateTo(lang.language) - } - - fun dontTranslateFrom(lang: String) { - account.addDontTranslateFrom(lang) - } - - fun prefer( - source: String, - target: String, - preference: String, - ) { - account.prefer(source, target, preference) - } - fun follow(user: User) { viewModelScope.launch(Dispatchers.IO) { account.follow(user) } } @@ -827,27 +803,6 @@ class AccountViewModel( fun isFollowing(user: HexKey): Boolean = account.isFollowing(user) - val hideDeleteRequestDialog: Boolean - get() = account.hideDeleteRequestDialog - - fun dontShowDeleteRequestDialog() { - viewModelScope.launch(Dispatchers.IO) { account.setHideDeleteRequestDialog() } - } - - val hideNIP17WarningDialog: Boolean - get() = account.hideNIP17WarningDialog - - fun dontShowNIP17WarningDialog() { - account.setHideNIP17WarningDialog() - } - - val hideBlockAlertDialog: Boolean - get() = account.hideBlockAlertDialog - - fun dontShowBlockAlertDialog() { - account.setHideBlockAlertDialog() - } - fun hideSensitiveContent() { account.updateShowSensitiveContent(false) } @@ -866,7 +821,7 @@ class AccountViewModel( } } - fun defaultZapType(): LnZapEvent.ZapType = account.defaultZapType.value + fun defaultZapType(): LnZapEvent.ZapType = account.settings.defaultZapType.value fun unwrap( event: GiftWrapEvent, @@ -1206,18 +1161,22 @@ class AccountViewModel( portNumber: MutableState, ) { viewModelScope.launch(Dispatchers.IO) { - account.proxyPort = portNumber.value.toInt() - account.proxy = HttpClientManager.initProxy(checked, "127.0.0.1", account.proxyPort) - account.saveable.invalidateData() + account.settings.updateProxy(checked, portNumber.value) Amethyst.instance.serviceManager.forceRestart() } } + fun restartServices() { + viewModelScope.launch(Dispatchers.IO) { + Amethyst.instance.serviceManager.restartIfDifferentAccount(account) + } + } + class Factory( - val account: Account, + val accountSettings: AccountSettings, val settings: SettingsState, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): AccountViewModel = AccountViewModel(account, settings) as AccountViewModel + override fun create(modelClass: Class): AccountViewModel = AccountViewModel(accountSettings, settings) as AccountViewModel } private var collectorJob: Job? = null @@ -1478,7 +1437,7 @@ class AccountViewModel( val noteEvent = note.event noteEvent is NIP90ContentDiscoveryResponseEvent && noteEvent.pubKey == pubkeyHex && - noteEvent.isTaggedUser(account.keyPair.pubKey.toHexKey()) && + noteEvent.isTaggedUser(account.signer.pubKey) && noteEvent.createdAt > fifteenMinsAgo }, comparator = CreatedAtComparator, @@ -1519,7 +1478,7 @@ class AccountViewModel( context: Context, ) { viewModelScope.launch(Dispatchers.IO) { - if (account.defaultZapType.value == LnZapEvent.ZapType.NONZAP) { + if (account.settings.defaultZapType.value == LnZapEvent.ZapType.NONZAP) { LightningAddressResolver() .lnAddressInvoice( lnaddress, @@ -1532,7 +1491,7 @@ class AccountViewModel( context = context, ) } else { - account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType.value) { zapRequest -> + account.createZapRequestFor(toUserPubKeyHex, message, account.settings.defaultZapType.value) { zapRequest -> LocalCache.justConsume(zapRequest, null) LightningAddressResolver() .lnAddressInvoice( @@ -1692,7 +1651,7 @@ fun mockAccountViewModel(): AccountViewModel { sharedPreferencesViewModel.init() return AccountViewModel( - Account( + AccountSettings( // blank keys keyPair = KeyPair( @@ -1700,7 +1659,6 @@ fun mockAccountViewModel(): AccountViewModel { pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"), forcePubKeyCheck = false, ), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ), sharedPreferencesViewModel.sharedPrefs, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 02e6cd78a..7882b8181 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -148,10 +148,8 @@ fun HiddenUsersScreen( Column(Modifier.fillMaxHeight()) { val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() - var warnAboutReports by remember { - mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) - } - var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) } + var warnAboutReports by remember { mutableStateOf(accountViewModel.account.settings.warnAboutPostsWithReports) } + var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.filterSpamFromStrangers) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( @@ -237,7 +235,10 @@ private fun AddMuteWordTextField(accountViewModel: AccountViewModel) { value = currentWordToAdd.value, onValueChange = { currentWordToAdd.value = it }, label = { Text(text = stringRes(R.string.hide_new_word_label)) }, - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp), placeholder = { Text( text = stringRes(R.string.hide_new_word_label), @@ -272,10 +273,10 @@ fun WatchAccountAndBlockList( accountViewModel: AccountViewModel, invalidate: () -> Unit, ) { - val accountState by accountViewModel.accountLiveData.observeAsState() + val transientSpammers by accountViewModel.account.transientHiddenUsers.collectAsStateWithLifecycle() val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, accountState, blockListState) { + LaunchedEffect(accountViewModel, transientSpammers, blockListState) { invalidate() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt index b87ef93cf..ad2671667 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt @@ -467,7 +467,7 @@ fun EditFieldRow( galleryUri = it, alt = null, sensitiveContent = false, - server = ServerOption(accountViewModel.account.defaultFileServer, false), + server = ServerOption(accountViewModel.account.settings.defaultFileServer, false), onError = accountViewModel::toast, context = context, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt index 6c1dcaf8b..8d84de275 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt @@ -447,7 +447,7 @@ fun PrivateMessageEditFieldRow( alt = null, sensitiveContent = false, isPrivate = isPrivate, - server = ServerOption(accountViewModel.account.defaultFileServer, false), + server = ServerOption(accountViewModel.account.settings.defaultFileServer, false), onError = accountViewModel::toast, context = context, ) @@ -467,7 +467,7 @@ fun PrivateMessageEditFieldRow( modifier = Size30Modifier, onClick = { if ( - !accountViewModel.hideNIP17WarningDialog && + !accountViewModel.account.settings.hideNIP17WarningDialog && !channelScreenModel.nip17 && !channelScreenModel.requiresNIP17 ) { @@ -549,13 +549,13 @@ fun NewFeatureNIP17AlertDialog( buttonIconResource = R.drawable.incognito, buttonText = stringRes(R.string.new_feature_nip17_activate), onClickDoOnce = { - scope.launch(Dispatchers.IO) { onConfirm() } + scope.launch { onConfirm() } onDismiss() }, onClickDontShowAgain = { - scope.launch(Dispatchers.IO) { + scope.launch { onConfirm() - accountViewModel.dontShowNIP17WarningDialog() + accountViewModel.account.settings.setHideNIP17WarningDialog() } onDismiss() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 46198d315..6461e8a8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -153,7 +152,12 @@ private fun FeedLoaded( key = { _, item -> item.id() }, contentType = { _, item -> item.javaClass.simpleName }, ) { _, item -> - val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() } + val defaultModifier = + remember { + Modifier + .fillMaxWidth() + .animateItemPlacement() + } Row(defaultModifier) { RenderCardItem( @@ -176,18 +180,20 @@ private fun ShowDonationCard( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val account by accountViewModel.account.live.observeAsState() - if (account?.account?.hasDonatedInThisVersion() == false) { - LoadNote( - BuildConfig.RELEASE_NOTES_ID, - accountViewModel, - ) { loadedNoteId -> - if (loadedNoteId != null) { - ZapTheDevsCard( - loadedNoteId, - accountViewModel, - nav, - ) + if (!accountViewModel.account.hasDonatedInThisVersion()) { + val donated by accountViewModel.account.observeDonatedInThisVersion().collectAsStateWithLifecycle() + if (!donated) { + LoadNote( + BuildConfig.RELEASE_NOTES_ID, + accountViewModel, + ) { loadedNoteId -> + if (loadedNoteId != null) { + ZapTheDevsCard( + loadedNoteId, + accountViewModel, + nav, + ) + } } } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 063ad9473..e3f84db2f 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -230,7 +230,7 @@ private fun TranslationMessage( DropdownMenuItem( text = { Row { - if (source in accountViewModel.account.dontTranslateFrom) { + if (source in accountViewModel.account.settings.dontTranslateFrom) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -251,17 +251,15 @@ private fun TranslationMessage( } }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.dontTranslateFrom(source) - langSettingsPopupExpanded = false - } + accountViewModel.account.settings.addDontTranslateFrom(source) + langSettingsPopupExpanded = false }, ) HorizontalDivider(thickness = DividerThickness) DropdownMenuItem( text = { Row { - if (accountViewModel.account.preferenceBetween(source, target) == source) { + if (accountViewModel.account.settings.preferenceBetween(source, target) == source) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -283,7 +281,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, source) + accountViewModel.account.settings.prefer(source, target, source) langSettingsPopupExpanded = false } }, @@ -291,7 +289,7 @@ private fun TranslationMessage( DropdownMenuItem( text = { Row { - if (accountViewModel.account.preferenceBetween(source, target) == target) { + if (accountViewModel.account.settings.preferenceBetween(source, target) == target) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -313,7 +311,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.prefer(source, target, target) + accountViewModel.account.settings.prefer(source, target, target) langSettingsPopupExpanded = false } }, @@ -326,7 +324,7 @@ private fun TranslationMessage( DropdownMenuItem( text = { Row { - if (lang.language in accountViewModel.account.translateTo) { + if (accountViewModel.account.settings.translateToContains(lang)) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -348,7 +346,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.translateTo(lang) + accountViewModel.account.settings.updateTranslateTo(lang) langSettingsPopupExpanded = false } }, @@ -375,13 +373,13 @@ fun TranslateAndWatchLanguageChanges( LanguageTranslatorService .autoTranslate( content, - accountViewModel.account.dontTranslateFrom, - accountViewModel.account.translateTo, + accountViewModel.account.settings.dontTranslateFrom, + accountViewModel.account.settings.translateTo, ).addOnCompleteListener { task -> if (task.isSuccessful && !content.equals(task.result.result, true)) { if (task.result.sourceLang != null && task.result.targetLang != null) { val preference = - accountViewModel.account.preferenceBetween( + accountViewModel.account.settings.preferenceBetween( task.result.sourceLang!!, task.result.targetLang!!, ) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt index ad31a341b..42e3304a8 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt @@ -41,17 +41,7 @@ class SinceAuthorPerRelayFilter( // don't send it. override fun isValidFor(forRelay: String) = authors == null || !authors[forRelay].isNullOrEmpty() - override fun toJson(forRelay: String): String { - // if authors is empty, but not null - val authorsForThisRelay = - if (authors != null) { - authors[forRelay]?.ifEmpty { null } - } else { - null - } - - return FilterSerializer.toJson(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) - } + override fun toJson(forRelay: String): String = FilterSerializer.toJson(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) override fun match( event: Event, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt index 8d8e03533..614660fda 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -139,6 +140,17 @@ class AdvertisedRelayListEvent( signer.sign(createdAt, KIND, tags, msg, onReady) } + + fun create( + list: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): AdvertisedRelayListEvent? { + val tags = createTagArray(list) + val msg = "" + + return signer.sign(createdAt, KIND, tags, msg) + } } @Immutable data class AdvertisedRelayInfo( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt index 61cf8aa49..26965bd4f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -97,5 +98,11 @@ class ChatMessageRelayListEvent( ) { signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): ChatMessageRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt index 2682c514e..720f8c286 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ContactListEvent.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.decodePublicKey import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils @Immutable data class Contact( @@ -118,6 +119,46 @@ class ContactListEvent( const val KIND = 3 const val ALT = "Follow List" + fun createFromScratch( + followUsers: List = emptyList(), + followTags: List = emptyList(), + followGeohashes: List = emptyList(), + followCommunities: List = emptyList(), + followEvents: List = emptyList(), + relayUse: Map? = emptyMap(), + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): ContactListEvent? { + val content = + if (relayUse != null) { + mapper.writeValueAsString(relayUse) + } else { + "" + } + + val tags = + listOf(arrayOf("alt", ALT)) + + followUsers.map { + if (it.relayUri != null) { + arrayOf("p", it.pubKeyHex, it.relayUri) + } else { + arrayOf("p", it.pubKeyHex) + } + } + + followTags.map { arrayOf("t", it) } + + followEvents.map { arrayOf("e", it) } + + followCommunities.map { + if (it.relay != null) { + arrayOf("a", it.toTag(), it.relay) + } else { + arrayOf("a", it.toTag()) + } + } + + followGeohashes.map { arrayOf("g", it) } + + return signer.sign(createdAt, KIND, tags.toTypedArray(), content) + } + fun createFromScratch( followUsers: List, followTags: List, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 50b7e9955..ced139145 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -33,6 +33,7 @@ import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.fasterxml.jackson.module.kotlin.addDeserializer import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.ATag diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt index 11500e797..789c1e65e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GiftWrapEvent.kt @@ -78,7 +78,14 @@ class GiftWrapEvent( ) { try { plainContent(signer) { giftStr -> - val gift = fromJson(giftStr) + val gift = + try { + fromJson(giftStr) + } catch (e: Exception) { + Log.w("GiftWrapEvent", "Couldn't Parse the content " + this.toNostrUri() + " " + giftStr) + return@plainContent + } + if (gift is WrappedEvent) { gift.host = HostStub(this.id, this.pubKey, this.kind) } @@ -87,7 +94,7 @@ class GiftWrapEvent( onReady(gift) } } catch (e: Exception) { - Log.w("GiftWrapEvent", "Couldn't Decrypt the content", e) + Log.w("GiftWrapEvent", "Couldn't Decrypt the content " + this.toNostrUri()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt index c9d20d852..7b3851448 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LnZapPrivateEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -47,5 +48,12 @@ class LnZapPrivateEvent( ) { signer.sign(createdAt, KIND, tags, content, onReady) } + + fun create( + signer: NostrSignerSync, + tags: Array> = emptyArray(), + content: String = "", + createdAt: Long = TimeUtils.now(), + ): LnZapPrivateEvent? = signer.sign(createdAt, KIND, tags, content) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt index 407a04c39..6e68a9a3a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt @@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils import java.io.ByteArrayInputStream import java.io.StringWriter @@ -176,6 +177,27 @@ class MetadataEvent( companion object { const val KIND = 0 + fun newUser( + name: String?, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): MetadataEvent? { + // Tries to not delete any existing attribute that we do not work with. + val currentJson = ObjectMapper().createObjectNode() + + name?.let { addIfNotBlank(currentJson, "name", it.trim()) } + val writer = StringWriter() + ObjectMapper().writeValue(writer, currentJson) + + val tags = mutableListOf>() + + tags.add( + arrayOf("alt", "User profile for ${name ?: currentJson.get("name").asText() ?: ""}"), + ) + + return signer.sign(createdAt, KIND, tags.toTypedArray(), writer.buffer.toString()) + } + fun updateFromPast( latest: MetadataEvent?, name: String?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/SearchRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/SearchRelayListEvent.kt index 16a632600..1c93aa251 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/SearchRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/SearchRelayListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -97,5 +98,11 @@ class SearchRelayListEvent( ) { signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): SearchRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt index 5728c3190..c6668a563 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt @@ -20,20 +20,18 @@ */ package com.vitorpamplona.quartz.signers -import android.util.Log -import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.Event -import com.vitorpamplona.quartz.events.EventFactory import com.vitorpamplona.quartz.events.LnZapPrivateEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent class NostrSignerInternal( val keyPair: KeyPair, ) : NostrSigner(keyPair.pubKey.toHexKey()) { + val signerSync = NostrSignerSync(keyPair) + override fun sign( createdAt: Long, kind: Int, @@ -41,46 +39,7 @@ class NostrSignerInternal( content: String, onReady: (T) -> Unit, ) { - if (keyPair.privKey == null) return - - if (isUnsignedPrivateEvent(kind, tags)) { - // this is a private zap - signPrivateZap(createdAt, kind, tags, content, onReady) - } else { - signNormal(createdAt, kind, tags, content, onReady) - } - } - - fun isUnsignedPrivateEvent( - kind: Int, - tags: Array>, - ): Boolean = - kind == LnZapRequestEvent.KIND && - tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() } - - fun signNormal( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - if (keyPair.privKey == null) return - - val id = Event.generateId(pubKey, createdAt, kind, tags, content) - val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey() - - onReady( - EventFactory.create( - id.toHexKey(), - pubKey, - createdAt, - kind, - tags, - content, - sig, - ) as T, - ) + signerSync.sign(createdAt, kind, tags, content)?.let { onReady(it) } } override fun nip04Encrypt( @@ -88,15 +47,7 @@ class NostrSignerInternal( toPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (keyPair.privKey == null) return - - onReady( - CryptoUtils.encryptNIP04( - decryptedContent, - keyPair.privKey, - toPublicKey.hexToByteArray(), - ), - ) + signerSync.nip04Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) } } override fun nip04Decrypt( @@ -104,16 +55,7 @@ class NostrSignerInternal( fromPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (keyPair.privKey == null) return - - try { - val sharedSecret = - CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray()) - - onReady(CryptoUtils.decryptNIP04(encryptedContent, sharedSecret)) - } catch (e: Exception) { - Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") - } + signerSync.nip04Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) } } override fun nip44Encrypt( @@ -121,16 +63,7 @@ class NostrSignerInternal( toPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (keyPair.privKey == null) return - - onReady( - CryptoUtils - .encryptNIP44( - decryptedContent, - keyPair.privKey, - toPublicKey.hexToByteArray(), - ).encodePayload(), - ) + signerSync.nip44Encrypt(decryptedContent, toPublicKey)?.let { onReady(it) } } override fun nip44Decrypt( @@ -138,119 +71,13 @@ class NostrSignerInternal( fromPublicKey: HexKey, onReady: (String) -> Unit, ) { - if (keyPair.privKey == null) return - - CryptoUtils - .decryptNIP44( - payload = encryptedContent, - privateKey = keyPair.privKey, - pubKey = fromPublicKey.hexToByteArray(), - )?.let { onReady(it) } - } - - private fun signPrivateZap( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - onReady: (T) -> Unit, - ) { - if (keyPair.privKey == null) return - - val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } - val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return - - // if it is a Zap for an Event, use event.id if not, use the user's pubkey - val idToGeneratePrivateKey = zappedEvent ?: userHex - - val encryptionPrivateKey = - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - idToGeneratePrivateKey, - createdAt, - ) - - val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() - - LnZapPrivateEvent.create(this, fullTagsNoAnon, content) { - val noteJson = it.toJson() - val encryptedContent = - LnZapRequestEvent.encryptPrivateZapMessage( - noteJson, - encryptionPrivateKey, - userHex.hexToByteArray(), - ) - - val newTags = - tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent)) - val newContent = "" - - NostrSignerInternal(KeyPair(encryptionPrivateKey)) - .signNormal(createdAt, kind, newTags.toTypedArray(), newContent, onReady) - } + signerSync.nip44Decrypt(encryptedContent, fromPublicKey)?.let { onReady(it) } } override fun decryptZapEvent( event: LnZapRequestEvent, onReady: (LnZapPrivateEvent) -> Unit, ) { - if (keyPair.privKey == null) return - - val recipientPK = event.zappedAuthor().firstOrNull() - val recipientPost = event.zappedPost().firstOrNull() - val privateEvent = - if (recipientPK == pubKey) { - // if the receiver is logged in, these are the params. - val privateKeyToUse = keyPair.privKey - val pubkeyToUse = event.pubKey - - event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) - } else { - // if the sender is logged in, these are the params - val altPubkeyToUse = recipientPK - val altPrivateKeyToUse = - if (recipientPost != null) { - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - recipientPost, - event.createdAt, - ) - } else if (recipientPK != null) { - LnZapRequestEvent.createEncryptionPrivateKey( - keyPair.privKey.toHexKey(), - recipientPK, - event.createdAt, - ) - } else { - null - } - - try { - if (altPrivateKeyToUse != null && altPubkeyToUse != null) { - val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey() - - if (altPubKeyFromPrivate == event.pubKey) { - val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) - - if (result == null) { - Log.w( - "Private ZAP Decrypt", - "Fail to decrypt Zap from ${event.id}", - ) - } - result - } else { - null - } - } else { - null - } - } catch (e: Exception) { - Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) - null - } - } - - privateEvent?.let { onReady(it) } + signerSync.decryptZapEvent(event)?.let { onReady(it) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerSync.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerSync.kt new file mode 100644 index 000000000..4e4732c56 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerSync.kt @@ -0,0 +1,241 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.signers + +import android.util.Log +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.EventFactory +import com.vitorpamplona.quartz.events.LnZapPrivateEvent +import com.vitorpamplona.quartz.events.LnZapRequestEvent + +class NostrSignerSync( + val keyPair: KeyPair, + val pubKey: HexKey = keyPair.pubKey.toHexKey(), +) { + fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T? { + if (keyPair.privKey == null) return null + + return if (isUnsignedPrivateZapEvent(kind, tags)) { + // this is a private zap + signPrivateZap(createdAt, kind, tags, content) + } else { + signNormal(createdAt, kind, tags, content) + } + } + + fun isUnsignedPrivateZapEvent( + kind: Int, + tags: Array>, + ): Boolean = + kind == LnZapRequestEvent.KIND && + tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() } + + fun signNormal( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T? { + if (keyPair.privKey == null) return null + + val id = Event.generateId(pubKey, createdAt, kind, tags, content) + val sig = CryptoUtils.sign(id, keyPair.privKey).toHexKey() + + return EventFactory.create( + id.toHexKey(), + pubKey, + createdAt, + kind, + tags, + content, + sig, + ) as T + } + + fun nip04Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + ): String? { + if (keyPair.privKey == null) return null + + return CryptoUtils.encryptNIP04( + decryptedContent, + keyPair.privKey, + toPublicKey.hexToByteArray(), + ) + } + + fun nip04Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + ): String? { + if (keyPair.privKey == null) return null + + return try { + val sharedSecret = + CryptoUtils.getSharedSecretNIP04(keyPair.privKey, fromPublicKey.hexToByteArray()) + + CryptoUtils.decryptNIP04(encryptedContent, sharedSecret) + } catch (e: Exception) { + Log.w("NIP04Decrypt", "Error decrypting the message ${e.message} on $encryptedContent") + null + } + } + + fun nip44Encrypt( + decryptedContent: String, + toPublicKey: HexKey, + ): String? { + if (keyPair.privKey == null) return null + + return CryptoUtils + .encryptNIP44( + decryptedContent, + keyPair.privKey, + toPublicKey.hexToByteArray(), + ).encodePayload() + } + + fun nip44Decrypt( + encryptedContent: String, + fromPublicKey: HexKey, + ): String? { + if (keyPair.privKey == null) return null + + return CryptoUtils + .decryptNIP44( + payload = encryptedContent, + privateKey = keyPair.privKey, + pubKey = fromPublicKey.hexToByteArray(), + ) + } + + private fun signPrivateZap( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T? { + if (keyPair.privKey == null) return null + + val zappedEvent = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } + val userHex = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } ?: return null + + // if it is a Zap for an Event, use event.id if not, use the user's pubkey + val idToGeneratePrivateKey = zappedEvent ?: userHex + + val encryptionPrivateKey = + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + idToGeneratePrivateKey, + createdAt, + ) + + val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() + + val privateEvent = LnZapPrivateEvent.create(this, fullTagsNoAnon, content) ?: return null + + val noteJson = privateEvent.toJson() + val encryptedContent = + LnZapRequestEvent.encryptPrivateZapMessage( + noteJson, + encryptionPrivateKey, + userHex.hexToByteArray(), + ) + + val newTags = + tags.filter { t -> t.getOrNull(0) != "anon" } + listOf(arrayOf("anon", encryptedContent)) + val newContent = "" + + return NostrSignerSync(KeyPair(encryptionPrivateKey)).signNormal(createdAt, kind, newTags.toTypedArray(), newContent) + } + + fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent? { + if (keyPair.privKey == null) return null + + val recipientPK = event.zappedAuthor().firstOrNull() + val recipientPost = event.zappedPost().firstOrNull() + val privateEvent = + if (recipientPK == pubKey) { + // if the receiver is logged in, these are the params. + val privateKeyToUse = keyPair.privKey + val pubkeyToUse = event.pubKey + + event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse) + } else { + // if the sender is logged in, these are the params + val altPubkeyToUse = recipientPK + val altPrivateKeyToUse = + if (recipientPost != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + recipientPost, + event.createdAt, + ) + } else if (recipientPK != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + keyPair.privKey.toHexKey(), + recipientPK, + event.createdAt, + ) + } else { + null + } + + try { + if (altPrivateKeyToUse != null && altPubkeyToUse != null) { + val altPubKeyFromPrivate = CryptoUtils.pubkeyCreate(altPrivateKeyToUse).toHexKey() + + if (altPubKeyFromPrivate == event.pubKey) { + val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) + + if (result == null) { + Log.w( + "Private ZAP Decrypt", + "Fail to decrypt Zap from ${event.id}", + ) + } + result + } else { + null + } + } else { + null + } + } catch (e: Exception) { + Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e) + null + } + } + + return privateEvent + } +}