- 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
This commit is contained in:
Vitor Pamplona 2024-08-26 14:42:55 -04:00
parent f3161ada8d
commit 4e3b6d0299
72 changed files with 2296 additions and 1736 deletions

View File

@ -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)

View File

@ -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()
}

View File

@ -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<String, String>(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)
}

View File

@ -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()

View File

@ -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<AccountInfo>? = null
private var cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
private var cachedAccounts: MutableMap<String, AccountSettings?> = 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<AccountInfo> {
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts == null) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
Event.mapper.readValue<List<AccountInfo>>(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<List<AccountInfo>>(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<AccountInfo>) =
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<AccountInfo> = savedAccounts()
suspend fun allSavedAccounts(): List<AccountInfo> = 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<Set<RelaySetupInfo>?>(it)
}
?: setOf<RelaySetupInfo>()
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<List<Long>?>(it) }
?.ifEmpty { DefaultZapAmounts }
?: DefaultZapAmounts
val reactionChoices =
getString(PrefKeys.REACTION_CHOICES, "[]")
?.let { Event.mapper.readValue<List<String>?>(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<Nip96MediaServers.ServerName>(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<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val zapPaymentRequestServer =
try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(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<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val languagePreferences = parseOrNull<Map<String, String>>(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<Map<HexKey, String>>(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<Map<String, String>?>(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<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(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<Map<String, Long>?>(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<Map<String, Long>>(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 <reified T> 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<T?>(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 <reified T> 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
}
}
}

View File

@ -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 =

View File

@ -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<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
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<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var localRelayServers: Set<String> = setOf(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: MutableStateFlow<List<Long>> = MutableStateFlow(DefaultZapAmounts),
var reactionChoices: MutableStateFlow<List<String>> = MutableStateFlow(DefaultReactions),
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType> = MutableStateFlow(LnZapEvent.ZapType.PUBLIC),
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultDiscoveryFollowList: MutableStateFlow<String> = 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<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(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<Long>) {
if (zapAmountChoices.value != newAmounts) {
zapAmountChoices.tryEmit(newAmounts)
saveAccountSettings()
}
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
if (zapPaymentRequest != newServer) {
zapPaymentRequest = newServer
saveAccountSettings()
}
}
fun changeReactionTypes(newTypes: List<String>) {
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<String>) {
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<Long> = lastReadPerRoute.value[route] ?: addLastRead(route, 0)
private fun addLastRead(
route: String,
timestampInSecs: Long,
): MutableStateFlow<Long> =
MutableStateFlow<Long>(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<RelaySetupInfo>) {
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,
)

View File

@ -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>(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>(AntiSpamState(this))
}
class AntiSpamState(

View File

@ -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()
}

View File

@ -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,
)

View File

@ -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<TypedFilter>? {

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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")

View File

@ -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<Pair<Account, List<String>>>,
remainingTos: List<Pair<AccountSettings, List<String>>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> 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<String>()
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<String>()
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
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<String>()
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
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)

View File

@ -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 },

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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),

View File

@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.ui.actions.relays
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? = account.localRelayServers.toList()
override fun getRelayList(): List<String> = account.settings.localRelayServers.toList()
override fun saveRelayList(urlList: List<String>) {
account.updateLocalRelayServers(urlList.toSet())
account.settings.updateLocalRelayServers(urlList.toSet())
}
}

View File

@ -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) }

View File

@ -32,12 +32,12 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverChatFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
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<Note> {
@ -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,
)

View File

@ -32,19 +32,19 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
open class DiscoverCommunityFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
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<Note> {
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,
)

View File

@ -36,7 +36,7 @@ open class DiscoverLiveFeedFilter(
) : AdditiveFeedFilter<Note>() {
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,
)

View File

@ -32,7 +32,7 @@ open class DiscoverMarketplaceFeedFilter(
) : AdditiveFeedFilter<Note>() {
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,
)

View File

@ -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,
)

View File

@ -33,11 +33,11 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
class HomeConversationsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
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<Note> {
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,
)

View File

@ -39,16 +39,16 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
class HomeNewThreadFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
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,
)

View File

@ -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,
)

View File

@ -47,18 +47,18 @@ import com.vitorpamplona.quartz.events.RepostEvent
class NotificationFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
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,
)

View File

@ -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<Note> {
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,
)

View File

@ -34,11 +34,11 @@ import com.vitorpamplona.quartz.events.VideoVerticalEvent
class VideoFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
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<Note> {
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,
)

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -262,10 +262,11 @@ class PollNoteViewModel : ViewModel() {
}
?: BigDecimal.ZERO
fun createZapOptionsThatMatchThePollingParameters(): List<Long> {
fun createZapOptionsThatMatchThePollingParameters(zapPaymentChoices: List<Long>): List<Long> {
val options =
account?.zapAmountChoices?.filter { isValidInputVoteAmount(it) }?.toMutableList()
?: mutableListOf()
zapPaymentChoices
.filter { isValidInputVoteAmount(it) }
.toMutableList()
if (options.isEmpty()) {
valueMinimum?.let { minimum ->
valueMaximum?.let { maximum ->

View File

@ -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<ZapPaymentHandler.Payable>) -> 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(

View File

@ -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<String>())
fun load() {
this.reactionSet = account.reactionChoices
this.reactionSet = accountSettings.reactionChoices.value
}
fun toListOfChoices(commaSeparatedAmounts: String): List<Long> = 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 <UpdateReactionTypeViewModel : ViewModel> create(modelClass: Class<UpdateReactionTypeViewModel>): UpdateReactionTypeViewModel = UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel
override fun <UpdateReactionTypeViewModel : ViewModel> create(modelClass: Class<UpdateReactionTypeViewModel>): 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() }

View File

@ -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<Long>())
@ -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<Long> = 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 <UpdateZapAmountViewModel : ViewModel> create(modelClass: Class<UpdateZapAmountViewModel>): UpdateZapAmountViewModel = UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel
override fun <UpdateZapAmountViewModel : ViewModel> create(modelClass: Class<UpdateZapAmountViewModel>): 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

View File

@ -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,

View File

@ -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 = {},
)
}

View File

@ -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 = {},
)
}

View File

@ -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) {

View File

@ -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))
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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>(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)
}
}

View File

@ -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))

View File

@ -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<AccountState> = account.live.map { it }
val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope)
// TODO: contact lists are not notes yet
// val kind3Relays: StateFlow<ContactListEvent?> = 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<String>,
) {
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 <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel = AccountViewModel(account, settings) as AccountViewModel
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): 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,
)

View File

@ -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()
}
}

View File

@ -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,
)

View File

@ -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()
},

View File

@ -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,
)
}
}
}
}

View File

@ -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!!,
)

View File

@ -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,

View File

@ -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<AdvertisedRelayInfo>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): AdvertisedRelayListEvent? {
val tags = createTagArray(list)
val msg = ""
return signer.sign(createdAt, KIND, tags, msg)
}
}
@Immutable data class AdvertisedRelayInfo(

View File

@ -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<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): ChatMessageRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}

View File

@ -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<Contact> = emptyList(),
followTags: List<String> = emptyList(),
followGeohashes: List<String> = emptyList(),
followCommunities: List<ATag> = emptyList(),
followEvents: List<String> = emptyList(),
relayUse: Map<String, ReadWrite>? = 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<Contact>,
followTags: List<String>,

View File

@ -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

View File

@ -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())
}
}

View File

@ -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<Array<String>> = emptyArray(),
content: String = "",
createdAt: Long = TimeUtils.now(),
): LnZapPrivateEvent? = signer.sign(createdAt, KIND, tags, content)
}
}

View File

@ -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<Array<String>>()
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?,

View File

@ -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<String>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): SearchRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}

View File

@ -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 <T : Event> 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<Array<String>>,
): Boolean =
kind == LnZapRequestEvent.KIND &&
tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() }
fun <T : Event> signNormal(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<T>(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 <T> signPrivateZap(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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) }
}
}

View File

@ -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 <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<Array<String>>,
): Boolean =
kind == LnZapRequestEvent.KIND &&
tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() }
fun <T : Event> signNormal(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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 <T> signPrivateZap(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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
}
}