diff --git a/README.md b/README.md index 74b6aa011..8e59b99b1 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Video Events (NIP-71) - [x] Moderated Communities (NIP-72) - [ ] Zap Goals (NIP-75) -- [ ] Arbitrary Custom App Data (NIP-78) +- [x] Arbitrary Custom App Data (NIP-78) - [x] Highlights (NIP-84) - [x] Notify Request (NIP-88/Draft) - [x] Recommended Application Handlers (NIP-89) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 2633d90f8..abef26950 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -26,7 +26,12 @@ 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.AccountLanguagePreferencesInternal +import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal +import com.vitorpamplona.amethyst.model.AccountSecurityPreferencesInternal import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal +import com.vitorpamplona.amethyst.model.AccountZapPreferencesInternal import com.vitorpamplona.amethyst.model.DefaultReactions import com.vitorpamplona.amethyst.model.DefaultZapAmounts import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS @@ -45,6 +50,7 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.Event @@ -53,11 +59,9 @@ 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.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -104,6 +108,7 @@ private object PrefKeys { const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" const val LATEST_MUTE_LIST = "latestMuteList" const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList" + const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData" 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 @@ -310,19 +315,7 @@ object LocalPreferences { } 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(settings.languagePreferences), - ) - putString(PrefKeys.TRANSLATE_TO, settings.translateTo) - putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value)) - putString( - PrefKeys.REACTION_CHOICES, - Event.mapper.writeValueAsString(settings.reactionChoices.value), - ) - putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name) + putString( PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(settings.defaultFileServer), @@ -404,6 +397,15 @@ object LocalPreferences { remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) } + if (settings.backupAppSpecificData != null) { + putString( + PrefKeys.LATEST_APP_SPECIFIC_DATA, + Event.mapper.writeValueAsString(settings.backupAppSpecificData), + ) + } else { + remove(PrefKeys.LATEST_APP_SPECIFIC_DATA) + } + 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) @@ -414,9 +416,6 @@ object LocalPreferences { putString(PrefKeys.TOR_SETTINGS, Event.mapper.writeValueAsString(settings.torSettings.toSettings())) - putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports) - putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers) - val regularMap = settings.lastReadPerRoute.value.mapValues { it.value.value @@ -428,12 +427,6 @@ object LocalPreferences { ) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) - if (settings.showSensitiveContent.value == null) { - remove(PrefKeys.SHOW_SENSITIVE_CONTENT) - } else { - putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!) - } - putString( PrefKeys.PENDING_ATTESTATIONS, Event.mapper.writeValueAsString(settings.pendingAttestations.value), @@ -510,9 +503,6 @@ object LocalPreferences { 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() - val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS val defaultStoriesFollowList = @@ -528,13 +518,12 @@ object LocalPreferences { } ?: LnZapEvent.ZapType.PUBLIC val localRelays = parseOrNull>(PrefKeys.RELAYS) ?: emptySet() - val reactionChoices = parseOrNull>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions - val zapAmountChoices = parseOrNull>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts - val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0] val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) + val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0] + val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() - val languagePreferences = parseOrNull>(PrefKeys.LANGUAGE_PREFS) ?: mapOf() + val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() val latestUserMetadata = parseEventOrNull(PrefKeys.LATEST_USER_METADATA) val latestContactList = parseEventOrNull(PrefKeys.LATEST_CONTACT_LIST) @@ -543,6 +532,54 @@ object LocalPreferences { val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) + val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) + + val syncedSettings = + if (latestAppSpecificData != null) { + null + } else { + // previous version. Delete this when ready. + val reactionChoices = parseOrNull>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions + val zapAmountChoices = parseOrNull>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts + + val languagePreferences = parseOrNull>(PrefKeys.LANGUAGE_PREFS) ?: mapOf() + + val showSensitiveContent = + if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { + getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false) + } else { + null + } + val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true) + val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) + + val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() + val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language + + AccountSyncedSettingsInternal( + reactions = + AccountReactionPreferencesInternal( + reactionChoices = reactionChoices, + ), + zaps = + AccountZapPreferencesInternal( + zapAmountChoices = zapAmountChoices, + defaultZapType = defaultZapType, + ), + languages = + AccountLanguagePreferencesInternal( + dontTranslateFrom = dontTranslateFrom, + languagePreferences = languagePreferences, + translateTo = translateTo, + ), + security = + AccountSecurityPreferencesInternal( + showSensitiveContent = showSensitiveContent, + warnAboutPostsWithReports = warnAboutReports, + filterSpamFromStrangers = filterSpam, + ), + ) + } val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) @@ -571,15 +608,6 @@ object LocalPreferences { parseOrNull(PrefKeys.TOR_SETTINGS) ?: TorSettings() } - val showSensitiveContent = - if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) { - getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false) - } else { - null - } - val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true) - val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) - val lastReadPerRoute = parseOrNull>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { MutableStateFlow(it.value) @@ -594,12 +622,6 @@ object LocalPreferences { externalSignerPackageName = externalSignerPackageName, localRelays = localRelays, localRelayServers = localRelayServers, - dontTranslateFrom = dontTranslateFrom, - languagePreferences = languagePreferences, - translateTo = translateTo, - zapAmountChoices = MutableStateFlow(zapAmountChoices.toImmutableList()), - reactionChoices = MutableStateFlow(reactionChoices.toImmutableList()), - defaultZapType = MutableStateFlow(defaultZapType), defaultFileServer = defaultFileServer, defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), @@ -616,10 +638,9 @@ object LocalPreferences { backupSearchRelayList = latestSearchRelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList, backupMuteList = latestMuteList, + backupAppSpecificData = latestAppSpecificData, + backupSyncedSettings = syncedSettings, torSettings = TorSettingsFlow.build(torSettings), - showSensitiveContent = MutableStateFlow(showSensitiveContent), - warnAboutPostsWithReports = warnAboutReports, - filterSpamFromStrangers = filterSpam, lastReadPerRoute = MutableStateFlow(lastReadPerRoute), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), pendingAttestations = MutableStateFlow(pendingAttestations), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index e58cacace..077ab760e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -115,7 +115,12 @@ class ServiceManager( HttpClientManager.setDefaultProxy(null) } - LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true + // Convert this into a flow + LocalCache.antiSpam.active = account + ?.settings + ?.syncedSettings + ?.security + ?.filterSpamFromStrangers ?: true Coil.setImageLoader { Amethyst.instance .imageLoaderBuilder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 80887afb2..a81e76089 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.switchMap +import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource @@ -44,9 +45,11 @@ 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.Nip47WalletConnect import com.vitorpamplona.quartz.encoders.RelayUrlFormatter import com.vitorpamplona.quartz.encoders.hexToByteArray import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent @@ -128,7 +131,9 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import java.math.BigDecimal +import java.util.Locale import java.util.UUID +import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resume @OptIn(DelicateCoroutinesApi::class) @@ -138,6 +143,10 @@ class Account( val signer: NostrSigner = settings.createSigner(), val scope: CoroutineScope, ) { + companion object { + const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings" + } + var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) data class PaymentRequest( @@ -959,7 +968,7 @@ class Account( getBlockListNote().flow().metadata.stateFlow, getMuteListNote().flow().metadata.stateFlow, transientHiddenUsers, - settings.showSensitiveContent, + settings.syncedSettings.security.showSensitiveContent, ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> checkNotInMainThread() emit(assembleLiveHiddenUsers(blockList.note, muteList.note, transientHiddenUsers, showSensitiveContent)) @@ -972,7 +981,7 @@ class Account( getBlockListNote(), getMuteListNote(), transientHiddenUsers.value, - settings.showSensitiveContent.value, + settings.syncedSettings.security.showSensitiveContent.value, ) }, ) @@ -1025,14 +1034,83 @@ class Account( fun updateOptOutOptions( warnReports: Boolean, filterSpam: Boolean, - ) { + ): Boolean { if (settings.updateOptOutOptions(warnReports, filterSpam)) { - LocalCache.antiSpam.active = settings.filterSpamFromStrangers - if (!settings.filterSpamFromStrangers) { + if (!settings.syncedSettings.security.filterSpamFromStrangers) { transientHiddenUsers.update { emptySet() } } + + sendNewAppSpecificData() + return true + } + return false + } + + fun updateShowSensitiveContent(show: Boolean?) { + if (settings.updateShowSensitiveContent(show)) { + sendNewAppSpecificData() + } + } + + fun changeReactionTypes(reactionSet: List) { + if (settings.changeReactionTypes(reactionSet)) { + sendNewAppSpecificData() + } + } + + fun updateZapAmounts( + amountSet: List, + selectedZapType: LnZapEvent.ZapType, + nip47Update: Nip47WalletConnect.Nip47URI?, + ) { + var changed = false + + if (settings.changeZapAmounts(amountSet)) changed = true + if (settings.changeDefaultZapType(selectedZapType)) changed = true + if (settings.changeZapPaymentRequest(nip47Update)) changed = true + + if (changed) { + sendNewAppSpecificData() + } + } + + fun toggleDontTranslateFrom(languageCode: String) { + settings.toggleDontTranslateFrom(languageCode) + sendNewAppSpecificData() + } + + fun updateTranslateTo(languageCode: Locale) { + if (settings.updateTranslateTo(languageCode)) { + sendNewAppSpecificData() + } + } + + fun prefer( + source: String, + target: String, + preference: String, + ) { + settings.prefer(source, target, preference) + sendNewAppSpecificData() + } + + private fun sendNewAppSpecificData() { + sendNewAppSpecificData(settings.syncedSettings.toInternal()) + } + + private fun sendNewAppSpecificData(toInternal: AccountSyncedSettingsInternal) { + signer.nip44Encrypt(Event.mapper.writeValueAsString(toInternal), signer.pubKey) { encrypted -> + AppSpecificDataEvent.create( + dTag = APP_SPECIFIC_DATA_D_TAG, + description = encrypted, + otherTags = emptyArray(), + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } } } @@ -2788,6 +2866,13 @@ class Account( } } + fun getAppSpecificDataNote(): AddressableNote { + val aTag = AppSpecificDataEvent.createTag(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG) + return LocalCache.getOrCreateAddressableNote(aTag) + } + + fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow + fun getBlockListNote(): AddressableNote { val aTag = ATag( @@ -3118,7 +3203,7 @@ class Account( return true } - if (!settings.warnAboutPostsWithReports) { + if (!settings.syncedSettings.security.warnAboutPostsWithReports) { return !isHidden(user) && // if user hasn't hided this author user.reportsBy(userProfile()).isEmpty() // if user has not reported this post @@ -3131,7 +3216,7 @@ class Account( } private fun isAcceptableDirect(note: Note): Boolean { - if (!settings.warnAboutPostsWithReports) { + if (!settings.syncedSettings.security.warnAboutPostsWithReports) { return !note.hasReportsBy(userProfile()) } return !note.hasReportsBy(userProfile()) && @@ -3355,8 +3440,6 @@ class Account( (event.hasAnyTaggedUser() || event.publicAndPrivateUserCache?.isNotEmpty() == true) } - fun updateShowSensitiveContent(show: Boolean?) = settings.updateShowSensitiveContent(show) - fun markAsRead( route: String, timestampInSecs: Long, @@ -3516,7 +3599,7 @@ class Account( } settings.backupPrivateHomeRelayList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved search relay list ${event.toJson()}") + Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") GlobalScope.launch(Dispatchers.IO) { event.privateTags(signer) { LocalCache.verifyAndConsume(event, null) @@ -3524,6 +3607,24 @@ class Account( } } + settings.backupAppSpecificData?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") + GlobalScope.launch(Dispatchers.IO) { + LocalCache.verifyAndConsume(event, null) + signer.decrypt(event.content, event.pubKey) { decrypted -> + try { + val syncedSettings = Event.mapper.readValue(decrypted) + settings.syncedSettings.updateFrom(syncedSettings) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) + e.printStackTrace() + AccountSyncedSettingsInternal() + } + } + } + } + settings.backupMuteList?.let { Log.d("AccountRegisterObservers", "Loading saved mute list ${it.toJson()}") GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) } @@ -3597,6 +3698,28 @@ class Account( } } + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") + getAppSpecificDataFlow().collect { + Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${userProfile().toBestDisplayName()}") + (it.note.event as? AppSpecificDataEvent)?.let { + signer.decrypt(it.content, it.pubKey) { decrypted -> + val syncedSettings = + try { + Event.mapper.readValue(decrypted) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) + e.printStackTrace() + AccountSyncedSettingsInternal() + } + + settings.updateAppSpecificData(it, syncedSettings) + } + } + } + } + scope.launch(Dispatchers.Default) { LocalCache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 72e01d61a..81d6f2731 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.amethyst.model -import android.content.res.Resources import androidx.compose.runtime.Stable -import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @@ -34,6 +32,7 @@ import com.vitorpamplona.quartz.encoders.Nip47WalletConnect import com.vitorpamplona.quartz.encoders.RelayUrlFormatter import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.LnZapEvent @@ -44,8 +43,6 @@ 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.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -61,17 +58,6 @@ val DefaultChannels = "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", ) -val DefaultReactions = - persistentListOf( - "\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), @@ -93,17 +79,6 @@ val DefaultSearchRelayList = RelayUrlFormatter.normalize("wss://relay.noswhere.com"), ) -val DefaultZapAmounts = persistentListOf(100L, 500L, 1000L) - -fun getLanguagesSpokenByUser(): Set { - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - val codedList = mutableSetOf() - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { codedList.add(it.language) } - } - return codedList -} - // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val GLOBAL_FOLLOWS = " Global " @@ -117,12 +92,6 @@ class AccountSettings( var externalSignerPackageName: String? = null, var localRelays: Set = Constants.defaultRelays.toSet(), var localRelayServers: Set = setOf(), - var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var languagePreferences: Map = mapOf(), - var translateTo: String = Locale.getDefault().language, - var zapAmountChoices: MutableStateFlow> = MutableStateFlow(DefaultZapAmounts), - var reactionChoices: MutableStateFlow> = MutableStateFlow(DefaultReactions), - val defaultZapType: MutableStateFlow = MutableStateFlow(LnZapEvent.ZapType.PUBLIC), var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), @@ -139,16 +108,19 @@ class AccountSettings( var backupSearchRelayList: SearchRelayListEvent? = null, var backupMuteList: MuteListEvent? = null, var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null, + var backupAppSpecificData: AppSpecificDataEvent? = null, + backupSyncedSettings: AccountSyncedSettingsInternal? = null, // only exist for migration purposes val torSettings: TorSettingsFlow = TorSettingsFlow(), - val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), - var warnAboutPostsWithReports: Boolean = true, - var filterSpamFromStrangers: Boolean = true, val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), ) { val saveable = MutableStateFlow(AccountSettingsUpdater(this)) + val syncedSettings: AccountSyncedSettings = + backupSyncedSettings?.let { AccountSyncedSettings(it) } + ?: AccountSyncedSettings(AccountSyncedSettingsInternal()) + class AccountSettingsUpdater( val accountSettings: AccountSettings, ) @@ -173,32 +145,40 @@ class AccountSettings( // Zaps and Reactions // --- - fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { - if (defaultZapType.value != zapType) { - defaultZapType.tryEmit(zapType) + fun changeDefaultZapType(zapType: LnZapEvent.ZapType): Boolean { + if (syncedSettings.zaps.defaultZapType.value != zapType) { + syncedSettings.zaps.defaultZapType.tryEmit(zapType) saveAccountSettings() + return true } + return false } - fun changeZapAmounts(newAmounts: List) { - if (zapAmountChoices.value != newAmounts) { - zapAmountChoices.tryEmit(newAmounts.toImmutableList()) + fun changeZapAmounts(newAmounts: List): Boolean { + if (syncedSettings.zaps.zapAmountChoices.value != newAmounts) { + syncedSettings.zaps.zapAmountChoices.tryEmit(newAmounts.toImmutableList()) saveAccountSettings() + return true } + return false } - fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) { - if (zapPaymentRequest != newServer) { - zapPaymentRequest = newServer + fun changeReactionTypes(newTypes: List): Boolean { + if (syncedSettings.reactions.reactionChoices.value != newTypes) { + syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList()) saveAccountSettings() + return true } + return false } - fun changeReactionTypes(newTypes: List) { - if (reactionChoices.value != newTypes) { - reactionChoices.tryEmit(newTypes.toImmutableList()) + fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean { + if (zapPaymentRequest != newServer) { + zapPaymentRequest = newServer saveAccountSettings() + return true } + return false } // --- @@ -260,22 +240,18 @@ class AccountSettings( // language services // --- fun toggleDontTranslateFrom(languageCode: String) { - if (!dontTranslateFrom.contains(languageCode)) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - saveAccountSettings() - } else { - dontTranslateFrom = dontTranslateFrom.minus(languageCode) - saveAccountSettings() - } + syncedSettings.languages.toggleDontTranslateFrom(languageCode) + saveAccountSettings() } - fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language) + fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language) - fun updateTranslateTo(languageCode: Locale) { - if (translateTo != languageCode.language) { - translateTo = languageCode.language + fun updateTranslateTo(languageCode: Locale): Boolean { + if (syncedSettings.languages.updateTranslateTo(languageCode)) { saveAccountSettings() + return true } + return false } fun prefer( @@ -283,23 +259,14 @@ class AccountSettings( target: String, preference: String, ) { - val key = "$source,$target" - if (key !in languagePreferences) { - languagePreferences = languagePreferences + Pair(key, preference) - saveAccountSettings() - } else { - if (languagePreferences.get(key) == preference) { - languagePreferences = languagePreferences.minus(key) - } else { - languagePreferences = languagePreferences + Pair(key, preference) - } - } + syncedSettings.languages.prefer(source, target, preference) + saveAccountSettings() } fun preferenceBetween( source: String, target: String, - ): String? = languagePreferences["$source,$target"] + ): String? = syncedSettings.languages.preferenceBetween(source, target) // ---- // Backup Lists @@ -382,6 +349,22 @@ class AccountSettings( } } + fun updateAppSpecificData( + appSettings: AppSpecificDataEvent?, + newSyncedSettings: AccountSyncedSettingsInternal, + ) { + if (appSettings == null || appSettings.content().isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupAppSpecificData?.id != appSettings.id) { + println("AABBCC Update App Specific Data") + backupAppSpecificData = appSettings + syncedSettings.updateFrom(newSyncedSettings) + + saveAccountSettings() + } + } + // ---- // Warning dialogs // ---- @@ -407,15 +390,6 @@ class AccountSettings( } } - fun updateShowSensitiveContent(show: Boolean?): Boolean { - if (showSensitiveContent.value != show) { - showSensitiveContent.update { show } - saveAccountSettings() - return true - } - return false - } - // --- // donations // --- @@ -509,16 +483,20 @@ class AccountSettings( // --- // filters // --- + fun updateShowSensitiveContent(show: Boolean?): Boolean { + if (syncedSettings.security.updateShowSensitiveContent(show)) { + saveAccountSettings() + return true + } + return false + } + fun updateOptOutOptions( warnReports: Boolean, filterSpam: Boolean, ): Boolean = - if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) { - warnAboutPostsWithReports = warnReports - filterSpamFromStrangers = filterSpam - + if (syncedSettings.security.updateOptOutOptions(warnReports, filterSpam)) { saveAccountSettings() - true } else { false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt new file mode 100644 index 000000000..4ea4baaae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -0,0 +1,208 @@ +/** + * 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 androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.quartz.events.LnZapEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import java.util.Locale + +@Stable +class AccountSyncedSettings( + internalSettings: AccountSyncedSettingsInternal, +) { + val reactions = AccountReactionPreferences(MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList())) + val zaps = + AccountZapPreferences( + MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()), + MutableStateFlow(internalSettings.zaps.defaultZapType), + ) + val languages = + AccountLanguagePreferences( + internalSettings.languages.dontTranslateFrom, + internalSettings.languages.languagePreferences, + internalSettings.languages.translateTo, + ) + val security = + AccountSecurityPreferences( + MutableStateFlow(internalSettings.security.showSensitiveContent), + internalSettings.security.warnAboutPostsWithReports, + internalSettings.security.filterSpamFromStrangers, + ) + + fun toInternal(): AccountSyncedSettingsInternal = + AccountSyncedSettingsInternal( + reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value), + zaps = + AccountZapPreferencesInternal( + zaps.zapAmountChoices.value, + zaps.defaultZapType.value, + ), + languages = + AccountLanguagePreferencesInternal( + languages.dontTranslateFrom, + languages.languagePreferences, + languages.translateTo, + ), + security = + AccountSecurityPreferencesInternal( + security.showSensitiveContent.value, + security.warnAboutPostsWithReports, + security.filterSpamFromStrangers, + ), + ) + + fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { + val newReactionChoices = syncedSettingsInternal.reactions.reactionChoices.toImmutableList() + if (!equalImmutableLists(reactions.reactionChoices.value, newReactionChoices)) { + reactions.reactionChoices.tryEmit(newReactionChoices) + } + + val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList() + if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) { + zaps.zapAmountChoices.tryEmit(newZapChoices) + } + + if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) { + zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType) + } + + if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) { + languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom + } + + if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) { + languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences + } + + if (languages.translateTo != syncedSettingsInternal.languages.translateTo) { + languages.translateTo = syncedSettingsInternal.languages.translateTo + } + + if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) { + security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent) + } + + if (security.filterSpamFromStrangers != syncedSettingsInternal.security.filterSpamFromStrangers) { + security.filterSpamFromStrangers = syncedSettingsInternal.security.filterSpamFromStrangers + } + + if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) { + security.warnAboutPostsWithReports = syncedSettingsInternal.security.warnAboutPostsWithReports + } + } +} + +@Stable +class AccountReactionPreferences( + var reactionChoices: MutableStateFlow>, +) + +@Stable +class AccountZapPreferences( + var zapAmountChoices: MutableStateFlow>, + val defaultZapType: MutableStateFlow, +) + +@Stable +class AccountLanguagePreferences( + var dontTranslateFrom: Set, + var languagePreferences: Map, + var translateTo: String, +) { + // --- + // language services + // --- + fun toggleDontTranslateFrom(languageCode: String) { + if (!dontTranslateFrom.contains(languageCode)) { + dontTranslateFrom = dontTranslateFrom.plus(languageCode) + } else { + dontTranslateFrom = dontTranslateFrom.minus(languageCode) + } + } + + fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language) + + fun updateTranslateTo(languageCode: Locale): Boolean { + if (translateTo != languageCode.language) { + translateTo = languageCode.language + return true + } + return false + } + + fun prefer( + source: String, + target: String, + preference: String, + ) { + val key = "$source,$target" + if (key !in languagePreferences) { + languagePreferences = languagePreferences + Pair(key, preference) + } else { + if (languagePreferences.get(key) == preference) { + languagePreferences = languagePreferences.minus(key) + } else { + languagePreferences = languagePreferences + Pair(key, preference) + } + } + } + + fun preferenceBetween( + source: String, + target: String, + ): String? = languagePreferences["$source,$target"] +} + +@Stable +class AccountSecurityPreferences( + val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), + var warnAboutPostsWithReports: Boolean = true, + var filterSpamFromStrangers: Boolean = true, +) { + fun updateShowSensitiveContent(show: Boolean?): Boolean { + if (showSensitiveContent.value != show) { + showSensitiveContent.update { show } + return true + } + return false + } + + // --- + // filters + // --- + fun updateOptOutOptions( + warnReports: Boolean, + filterSpam: Boolean, + ): Boolean = + if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) { + warnAboutPostsWithReports = warnReports + filterSpamFromStrangers = filterSpam + + true + } else { + false + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt new file mode 100644 index 000000000..04296f5b2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -0,0 +1,76 @@ +/** + * 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.core.os.ConfigurationCompat +import com.vitorpamplona.quartz.events.LnZapEvent +import java.util.Locale + +val DefaultReactions = + listOf( + "\uD83D\uDE80", + "\uD83E\uDEC2", + "\uD83D\uDC40", + "\uD83D\uDE02", + "\uD83C\uDF89", + "\uD83E\uDD14", + "\uD83D\uDE31", + ) + +val DefaultZapAmounts = listOf(100L, 500L, 1000L) + +fun getLanguagesSpokenByUser(): Set { + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + val codedList = mutableSetOf() + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { codedList.add(it.language) } + } + return codedList +} + +class AccountSyncedSettingsInternal( + val reactions: AccountReactionPreferencesInternal = AccountReactionPreferencesInternal(), + val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(), + val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(), + val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(), +) + +class AccountReactionPreferencesInternal( + var reactionChoices: List = DefaultReactions, +) + +class AccountZapPreferencesInternal( + var zapAmountChoices: List = DefaultZapAmounts, + val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, +) + +class AccountLanguagePreferencesInternal( + var dontTranslateFrom: Set = getLanguagesSpokenByUser(), + var languagePreferences: Map = mapOf(), + var translateTo: String = Locale.getDefault().language, +) + +class AccountSecurityPreferencesInternal( + val showSensitiveContent: Boolean? = null, + var warnAboutPostsWithReports: Boolean = true, + var filterSpamFromStrangers: Boolean = true, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1e38d1de3..84673489a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AppDefinitionEvent import com.vitorpamplona.quartz.events.AppRecommendationEvent +import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.BadgeAwardEvent @@ -1232,6 +1233,13 @@ object LocalCache { consumeBaseReplaceable(event, relay) } + fun consume( + event: AppSpecificDataEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + @Suppress("UNUSED_PARAMETER") fun consume(event: RecommendRelayEvent) { // // Log.d("RR", event.toJson()) @@ -2645,6 +2653,7 @@ object LocalCache { is AdvertisedRelayListEvent -> consume(event, relay) is AppDefinitionEvent -> consume(event, relay) is AppRecommendationEvent -> consume(event, relay) + is AppSpecificDataEvent -> consume(event, relay) is AudioHeaderEvent -> consume(event, relay) is AudioTrackEvent -> consume(event, relay) is BadgeAwardEvent -> consume(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 08d630c66..76ef57633 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.events.AppSpecificDataEvent import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeProfilesEvent import com.vitorpamplona.quartz.events.BookmarkListEvent @@ -78,35 +79,15 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { val latestEOSEs = EOSEAccount() val hasLoadedTheBasics = mutableMapOf() - fun createAccountContactListFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ContactListEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1, - ), - ) - fun createAccountMetadataFilter(): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - limit = 1, - ), - ) - - fun createAccountRelayListFilter(): TypedFilter = TypedFilter( types = COMMON_FEED_TYPES, filter = SincePerRelayFilter( kinds = listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, @@ -138,7 +119,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { PeopleListEvent.KIND, ), authors = otherAuthors, - limit = 100, + limit = otherAuthors.size * 10, ), ) } @@ -154,6 +135,18 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { ), ) + fun createAccountSettings2Filter(): TypedFilter = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = listOf(AppSpecificDataEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)), + limit = 1, + ), + ) + fun createAccountReportsFilter(): TypedFilter = TypedFilter( types = COMMON_FEED_TYPES, @@ -465,8 +458,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { accountChannel.typedFilters = listOfNotNull( createAccountMetadataFilter(), - createAccountContactListFilter(), - createAccountRelayListFilter(), + createAccountSettings2Filter(), createNotificationFilter(), createNotificationFilter2(), createGiftWrapsToMeFilter(), @@ -480,9 +472,8 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") { accountChannel.typedFilters = listOf( createAccountMetadataFilter(), - createAccountContactListFilter(), - createAccountRelayListFilter(), createAccountSettingsFilter(), + createAccountSettings2Filter(), ).ifEmpty { null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index c679106ad..99ce44f9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -97,9 +97,7 @@ fun SensitivityWarning( accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { - val accountState = - accountViewModel.account.settings.showSensitiveContent - .collectAsStateWithLifecycle() + val accountState = accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 0aa0ae311..fd5d1651d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -525,16 +525,12 @@ fun ZapVote( ) return@combinedClickable } else if ( - accountViewModel.account.settings.zapAmountChoices.value.size == 1 && - pollViewModel.isValidInputVoteAmount( - accountViewModel.account.settings.zapAmountChoices.value - .first(), - ) + accountViewModel.zapAmountChoices().size == 1 && + pollViewModel.isValidInputVoteAmount(accountViewModel.zapAmountChoices().first()) ) { accountViewModel.zap( baseNote, - accountViewModel.account.settings.zapAmountChoices.value - .first() * 1000, + accountViewModel.zapAmountChoices().first() * 1000, poolOption.option, "", context, @@ -667,7 +663,7 @@ fun FilteredZapAmountChoicePopup( val context = LocalContext.current // TODO: Move this to the viewModel - val zapPaymentChoices by accountViewModel.account.settings.zapAmountChoices + val zapPaymentChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() val zapMessage = "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 3857ae8db..680fcd8b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -965,9 +965,9 @@ private fun likeClick( ) return } - if (accountViewModel.account.settings.reactionChoices.value - .isEmpty() - ) { + + val choices = accountViewModel.reactionChoices() + if (choices.isEmpty()) { accountViewModel.toast( R.string.no_reactions_setup, R.string.no_reaction_type_setup_long_press_to_change, @@ -977,9 +977,9 @@ private fun likeClick( R.string.read_only_user, R.string.login_with_a_private_key_to_like_posts, ) - } else if (accountViewModel.account.settings.reactionChoices.value.size == 1) { + } else if (choices.size == 1) { onWantsToSignReaction() - } else if (accountViewModel.account.settings.reactionChoices.value.size > 1) { + } else if (choices.size > 1) { onMultipleChoices() } } @@ -1181,9 +1181,9 @@ fun zapClick( return } - if (accountViewModel.account.settings.zapAmountChoices.value - .isEmpty() - ) { + val choices = accountViewModel.zapAmountChoices() + + if (choices.isEmpty()) { accountViewModel.toast( R.string.error_dialog_zap_error, R.string.no_zap_amount_setup_long_press_to_change, @@ -1193,11 +1193,10 @@ 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.settings.zapAmountChoices.value.size == 1) { + } else if (choices.size == 1) { accountViewModel.zap( baseNote, - accountViewModel.account.settings.zapAmountChoices.value - .first() * 1000, + choices.first() * 1000, null, "", context, @@ -1205,7 +1204,7 @@ fun zapClick( onProgress = { onZappingProgress(it) }, onPayViaIntent = onPayViaIntent, ) - } else if (accountViewModel.account.settings.zapAmountChoices.value.size > 1) { + } else if (choices.size > 1) { onMultipleChoices() } } @@ -1408,8 +1407,7 @@ fun ReactionChoicePopup( ) { val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } - val reactions by accountViewModel.account.settings.reactionChoices - .collectAsStateWithLifecycle() + val reactions by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle() val toRemove = remember { baseNote.reactedBy(accountViewModel.userProfile()).toImmutableSet() } Popup( @@ -1573,7 +1571,7 @@ fun ZapAmountChoicePopup( onPayViaIntent: (ImmutableList) -> Unit, ) { val zapAmountChoices by - accountViewModel.account.settings.zapAmountChoices + accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index b2c521e3f..0333d2085 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -49,7 +49,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -69,12 +68,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties 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 import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.firstFullChar import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer @@ -94,16 +93,17 @@ import com.vitorpamplona.quartz.events.EmojiUrl import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class UpdateReactionTypeViewModel( - val accountSettings: AccountSettings, -) : ViewModel() { +class UpdateReactionTypeViewModel : ViewModel() { + var account: Account? = null var nextChoice by mutableStateOf(TextFieldValue("")) var reactionSet by mutableStateOf(listOf()) - fun load() { - this.reactionSet = accountSettings.reactionChoices.value + fun load(myAccount: Account) { + this.account = myAccount + this.reactionSet = myAccount.settings.syncedSettings.reactions.reactionChoices.value } fun toListOfChoices(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } @@ -124,21 +124,24 @@ class UpdateReactionTypeViewModel( } fun sendPost() { - accountSettings.changeReactionTypes(reactionSet) - nextChoice = TextFieldValue("") + viewModelScope.launch(Dispatchers.IO) { + account?.changeReactionTypes(reactionSet) + nextChoice = TextFieldValue("") + } } fun cancel() { nextChoice = TextFieldValue("") } - fun hasChanged(): Boolean = reactionSet != accountSettings.reactionChoices.value - - class Factory( - val accountSettings: AccountSettings, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UpdateReactionTypeViewModel = UpdateReactionTypeViewModel(accountSettings) as UpdateReactionTypeViewModel - } + fun hasChanged(): Boolean = + reactionSet != + account + ?.settings + ?.syncedSettings + ?.reactions + ?.reactionChoices + ?.value } @OptIn(ExperimentalLayoutApi::class) @@ -148,14 +151,20 @@ fun UpdateReactionTypeDialog( accountViewModel: AccountViewModel, nav: INav, ) { - val postViewModel: UpdateReactionTypeViewModel = - viewModel( - key = "UpdateReactionTypeViewModel", - factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account.settings), - ) + val postViewModel: UpdateReactionTypeViewModel = viewModel() + postViewModel.load(accountViewModel.account) - LaunchedEffect(accountViewModel) { postViewModel.load() } + UpdateReactionTypeDialog(postViewModel, onClose, accountViewModel, nav) +} +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun UpdateReactionTypeDialog( + postViewModel: UpdateReactionTypeViewModel, + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { Dialog( onDismissRequest = { onClose() }, properties = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 7428fcd96..029feef52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -82,10 +82,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton @@ -106,10 +106,12 @@ import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.LnZapEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class UpdateZapAmountViewModel : ViewModel() { + var account: Account? = null -class UpdateZapAmountViewModel( - val accountSettings: AccountSettings, -) : ViewModel() { var nextAmount by mutableStateOf(TextFieldValue("")) var amountSet by mutableStateOf(listOf()) var walletConnectRelay by mutableStateOf(TextFieldValue("")) @@ -124,15 +126,23 @@ class UpdateZapAmountViewModel( updateNIP47(text) } - fun load() { - this.amountSet = accountSettings.zapAmountChoices.value + fun load(myAccount: Account) { + this.account = myAccount + this.amountSet = myAccount.settings.syncedSettings.zaps.zapAmountChoices.value + this.selectedZapType = myAccount.settings.syncedSettings.zaps.defaultZapType.value + this.walletConnectPubkey = - accountSettings.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") + myAccount.settings.zapPaymentRequest + ?.pubKeyHex + ?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = - accountSettings.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") + myAccount.settings.zapPaymentRequest + ?.relayUri + ?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectSecret = - accountSettings.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") - this.selectedZapType = accountSettings.defaultZapType.value + myAccount.settings.zapPaymentRequest + ?.secret + ?.let { TextFieldValue(it) } ?: TextFieldValue("") } fun toListOfAmounts(commaSeparatedAmounts: String): List = commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } @@ -151,37 +161,37 @@ class UpdateZapAmountViewModel( } fun sendPost() { - accountSettings.changeZapAmounts(amountSet) - accountSettings.changeDefaultZapType(selectedZapType) - - if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { - val pubkeyHex = - try { - decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } + val nip47Update = + if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { + val pubkeyHex = + try { + decodePublicKey(walletConnectPubkey.text.trim()).toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } - val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlFormatter.normalize(it) } - val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } + val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlFormatter.normalize(it) } + val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } - if (pubkeyHex != null && relayUrl != null) { - accountSettings.changeZapPaymentRequest( + if (pubkeyHex != null && relayUrl != null) { Nip47WalletConnect.Nip47URI( pubkeyHex, relayUrl, privKeyHex, - ), - ) + ) + } else { + null + } } else { - accountSettings.changeZapPaymentRequest(null) + null } - } else { - accountSettings.changeZapPaymentRequest(null) - } - nextAmount = TextFieldValue("") + viewModelScope.launch(Dispatchers.IO) { + account?.updateZapAmounts(amountSet, selectedZapType, nip47Update) + + nextAmount = TextFieldValue("") + } } fun cancel() { @@ -190,11 +200,23 @@ class UpdateZapAmountViewModel( fun hasChanged(): Boolean = ( - selectedZapType != accountSettings.defaultZapType.value || - amountSet != accountSettings.zapAmountChoices.value || - walletConnectPubkey.text != (accountSettings.zapPaymentRequest?.pubKeyHex ?: "") || - walletConnectRelay.text != (accountSettings.zapPaymentRequest?.relayUri ?: "") || - walletConnectSecret.text != (accountSettings.zapPaymentRequest?.secret ?: "") + selectedZapType != + account + ?.settings + ?.syncedSettings + ?.zaps + ?.defaultZapType + ?.value || + amountSet != + account + ?.settings + ?.syncedSettings + ?.zaps + ?.zapAmountChoices + ?.value || + walletConnectPubkey.text != (account?.settings?.zapPaymentRequest?.pubKeyHex ?: "") || + walletConnectRelay.text != (account?.settings?.zapPaymentRequest?.relayUri ?: "") || + walletConnectSecret.text != (account?.settings?.zapPaymentRequest?.secret ?: "") ) fun updateNIP47(uri: String) { @@ -205,17 +227,22 @@ class UpdateZapAmountViewModel( walletConnectSecret = TextFieldValue(contact.secret ?: "") } } +} - class Factory( - val accountSettings: AccountSettings, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UpdateZapAmountViewModel = UpdateZapAmountViewModel(accountSettings) as UpdateZapAmountViewModel - } +@Composable +fun UpdateZapAmountDialog( + onClose: () -> Unit, + nip47uri: String? = null, + accountViewModel: AccountViewModel, +) { + val postViewModel: UpdateZapAmountViewModel = viewModel() + postViewModel.load(accountViewModel.account) + UpdateZapAmountDialog(postViewModel, onClose, nip47uri, accountViewModel) } -@OptIn(ExperimentalLayoutApi::class) @Composable fun UpdateZapAmountDialog( + postViewModel: UpdateZapAmountViewModel, onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel, @@ -233,12 +260,6 @@ fun UpdateZapAmountDialog( modifier = Modifier.fillMaxWidth(), ) { Column { - val postViewModel: UpdateZapAmountViewModel = - viewModel( - key = "UpdateZapAmountViewModel", - factory = UpdateZapAmountViewModel.Factory(accountViewModel.account.settings), - ) - Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -311,7 +332,6 @@ fun UpdateZapAmountContent( } LaunchedEffect(accountViewModel, nip47uri) { - postViewModel.load() if (nip47uri != null) { try { postViewModel.updateNIP47(nip47uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 3000ee0c9..eb1ddab57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -153,7 +153,7 @@ fun ZapCustomDialog( } var selectedZapType by - remember(accountViewModel) { mutableStateOf(accountViewModel.account.settings.defaultZapType.value) } + remember(accountViewModel) { mutableStateOf(accountViewModel.defaultZapType()) } Dialog( onDismissRequest = { onClose() }, @@ -224,7 +224,7 @@ fun ZapCustomDialog( label = stringRes(id = R.string.zap_type), placeholder = zapTypes - .filter { it.first == accountViewModel.account.settings.defaultZapType.value } + .filter { it.first == accountViewModel.defaultZapType() } .first() .second, options = zapOptions, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 7e104ea47..accda0ccd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -315,10 +315,8 @@ fun NoteDropDownMenu( DropdownMenuItem( text = { Text(stringRes(R.string.content_warning_hide_all_sensitive_content)) }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.hideSensitiveContent() - onDismiss() - } + accountViewModel.hideSensitiveContent() + onDismiss() }, ) } @@ -326,10 +324,8 @@ fun NoteDropDownMenu( DropdownMenuItem( text = { Text(stringRes(R.string.content_warning_show_all_sensitive_content)) }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.disableContentWarnings() - onDismiss() - } + accountViewModel.disableContentWarnings() + onDismiss() }, ) } @@ -337,10 +333,8 @@ fun NoteDropDownMenu( DropdownMenuItem( text = { Text(stringRes(R.string.content_warning_see_warnings)) }, onClick = { - scope.launch(Dispatchers.IO) { - accountViewModel.seeContentWarnings() - onDismiss() - } + accountViewModel.seeContentWarnings() + onDismiss() }, ) } @@ -385,7 +379,8 @@ fun WatchBookmarksFollowsAndAccount( .live() .bookmarks .observeAsState() - val showSensitiveContent by accountViewModel.account.settings.showSensitiveContent + val showSensitiveContent by accountViewModel + .showSensitiveContent() .collectAsStateWithLifecycle() LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index 25be95a48..c16749b89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -455,9 +455,9 @@ fun customZapClick( return } - if (accountViewModel.account.settings.zapAmountChoices.value - .isEmpty() - ) { + val choices = accountViewModel.zapAmountChoices() + + if (choices.isEmpty()) { accountViewModel.toast( stringRes(context, R.string.error_dialog_zap_error), stringRes(context, R.string.no_zap_amount_setup_long_press_to_change), @@ -467,10 +467,8 @@ 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.settings.zapAmountChoices.value.size == 1) { - val amount = - accountViewModel.account.settings.zapAmountChoices.value - .first() + } else if (choices.size == 1) { + val amount = choices.first() if (amount > 1100) { accountViewModel.zap( @@ -488,11 +486,9 @@ fun customZapClick( onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) // recommends amounts for a monthly release. } - } 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 if (choices.size > 1) { + if (choices.any { it > 1100 }) { + onMultipleChoices(choices) } else { onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 6b108ba8f..1cb4bf7ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -120,6 +120,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -328,9 +329,7 @@ class AccountViewModel( fun reactToOrDelete(note: Note) { viewModelScope.launch(Dispatchers.IO) { - val reaction = - account.settings.reactionChoices.value - .first() + val reaction = reactionChoices().first() if (hasReactedTo(note, reaction)) { deleteReactionTo(note, reaction) } else { @@ -714,7 +713,7 @@ class AccountViewModel( onProgress(it) }, onPayViaIntent = onPayViaIntent, - zapType = zapType ?: account.settings.defaultZapType.value, + zapType = zapType ?: defaultZapType(), ) } } @@ -885,24 +884,55 @@ class AccountViewModel( fun isFollowing(user: HexKey): Boolean = account.isFollowing(user) fun hideSensitiveContent() { - account.updateShowSensitiveContent(false) + viewModelScope.launch(Dispatchers.IO) { + account.updateShowSensitiveContent(false) + } } fun disableContentWarnings() { - account.updateShowSensitiveContent(true) + viewModelScope.launch(Dispatchers.IO) { + account.updateShowSensitiveContent(true) + } } fun seeContentWarnings() { - account.updateShowSensitiveContent(null) + viewModelScope.launch(Dispatchers.IO) { + account.updateShowSensitiveContent(null) + } } fun markDonatedInThisVersion() { - viewModelScope.launch { - account.markDonatedInThisVersion() - } + account.markDonatedInThisVersion() } - fun defaultZapType(): LnZapEvent.ZapType = account.settings.defaultZapType.value + fun dontTranslateFrom() = account.settings.syncedSettings.languages.dontTranslateFrom + + fun translateTo() = account.settings.syncedSettings.languages.translateTo + + fun defaultZapType() = account.settings.syncedSettings.zaps.defaultZapType.value + + fun showSensitiveContent(): MutableStateFlow = account.settings.syncedSettings.security.showSensitiveContent + + fun zapAmountChoicesFlow() = account.settings.syncedSettings.zaps.zapAmountChoices + + fun zapAmountChoices() = zapAmountChoicesFlow().value + + fun reactionChoicesFlow() = account.settings.syncedSettings.reactions.reactionChoices + + fun reactionChoices() = reactionChoicesFlow().value + + fun filterSpamFromStrangers() = account.settings.syncedSettings.security.filterSpamFromStrangers + + fun updateOptOutOptions( + warnReports: Boolean, + filterSpam: Boolean, + ) { + viewModelScope.launch(Dispatchers.IO) { + if (account.updateOptOutOptions(warnReports, filterSpam)) { + LocalCache.antiSpam.active = filterSpamFromStrangers() + } + } + } fun unwrap( event: GiftWrapEvent, @@ -1539,7 +1569,7 @@ class AccountViewModel( context: Context, ) { viewModelScope.launch(Dispatchers.IO) { - if (account.settings.defaultZapType.value == LnZapEvent.ZapType.NONZAP) { + if (defaultZapType() == LnZapEvent.ZapType.NONZAP) { LightningAddressResolver() .lnAddressInvoice( lnaddress, @@ -1553,7 +1583,7 @@ class AccountViewModel( context = context, ) } else { - account.createZapRequestFor(toUserPubKeyHex, message, account.settings.defaultZapType.value) { zapRequest -> + account.createZapRequestFor(toUserPubKeyHex, message, defaultZapType()) { zapRequest -> LocalCache.justConsume(zapRequest, null) LightningAddressResolver() .lnAddressInvoice( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt index 1da1bac77..66840aacd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NIP47SetupScreen.kt @@ -42,19 +42,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton import com.vitorpamplona.amethyst.ui.stringRes -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NIP47SetupScreen( accountViewModel: AccountViewModel, nav: INav, nip47: String?, ) { - val postViewModel: UpdateZapAmountViewModel = - viewModel( - key = "UpdateZapAmountViewModel", - factory = UpdateZapAmountViewModel.Factory(accountViewModel.account.settings), - ) + val postViewModel: UpdateZapAmountViewModel = viewModel() + postViewModel.load(accountViewModel.account) + NIP47SetupScreen(postViewModel, accountViewModel, nav, nip47) +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NIP47SetupScreen( + postViewModel: UpdateZapAmountViewModel, + accountViewModel: AccountViewModel, + nav: INav, + nip47: String?, +) { Scaffold( topBar = { TopAppBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index fcd8b4cf4..334672c81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -157,15 +157,15 @@ fun SecurityFiltersScreen( Column(Modifier.padding(it).fillMaxHeight()) { val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() - var warnAboutReports by remember { mutableStateOf(accountViewModel.account.settings.warnAboutPostsWithReports) } - var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.filterSpamFromStrangers) } + var warnAboutReports by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports) } + var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( checked = warnAboutReports, onCheckedChange = { warnAboutReports = it - accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) + accountViewModel.updateOptOutOptions(warnAboutReports, filterSpam) }, ) @@ -177,7 +177,7 @@ fun SecurityFiltersScreen( checked = filterSpam, onCheckedChange = { filterSpam = it - accountViewModel.account.updateOptOutOptions(warnAboutReports, filterSpam) + accountViewModel.updateOptOutOptions(warnAboutReports, filterSpam) }, ) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index f72615ca5..f9439234a 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -234,7 +234,7 @@ private fun TranslationMessage( DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { - if (source in accountViewModel.account.settings.dontTranslateFrom) { + if (source in accountViewModel.dontTranslateFrom()) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -255,7 +255,7 @@ private fun TranslationMessage( } }, onClick = { - accountViewModel.account.settings.toggleDontTranslateFrom(source) + accountViewModel.account.toggleDontTranslateFrom(source) langSettingsPopupExpanded = false }, ) @@ -285,7 +285,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.account.settings.prefer(source, target, source) + accountViewModel.account.prefer(source, target, source) langSettingsPopupExpanded = false } }, @@ -293,7 +293,9 @@ private fun TranslationMessage( DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.preferenceBetween(source, target) == target) { + if (accountViewModel.account.settings.syncedSettings.languages + .preferenceBetween(source, target) == target + ) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -315,7 +317,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.account.settings.prefer(source, target, target) + accountViewModel.account.prefer(source, target, target) langSettingsPopupExpanded = false } }, @@ -350,7 +352,7 @@ private fun TranslationMessage( }, onClick = { scope.launch(Dispatchers.IO) { - accountViewModel.account.settings.updateTranslateTo(lang) + accountViewModel.account.updateTranslateTo(lang) langSettingsPopupExpanded = false } }, @@ -377,8 +379,8 @@ fun TranslateAndWatchLanguageChanges( LanguageTranslatorService .autoTranslate( content, - accountViewModel.account.settings.dontTranslateFrom, - accountViewModel.account.settings.translateTo, + accountViewModel.dontTranslateFrom(), + accountViewModel.translateTo(), ).addOnCompleteListener { task -> if (task.isSuccessful && !content.equals(task.result.result, true)) { if (task.result.sourceLang != null && task.result.targetLang != null) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AppSpecificDataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppSpecificDataEvent.kt new file mode 100644 index 000000000..59ce91c3c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AppSpecificDataEvent.kt @@ -0,0 +1,70 @@ +/** + * 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.events + +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +class AppSpecificDataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30078 + const val ALT = "Arbitrary app data" + + fun createTag( + pubkey: HexKey, + dTag: String, + ): ATag = ATag(KIND, pubkey, dTag, null) + + fun create( + dTag: String, + description: String, + otherTags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (AppSpecificDataEvent) -> Unit, + ) { + val withD = + if (otherTags.any { it.size > 1 && it[0] == "d" && it[1] == dTag }) { + otherTags + } else { + otherTags.filter { it.size > 0 && it[0] != "d" }.toTypedArray() + arrayOf("d", dTag) + } + + val newTags = + if (withD.none { it.size > 0 && it[0] == "alt" }) { + withD + arrayOf("alt", ALT) + } else { + withD + } + + signer.sign(createdAt, KIND, newTags, description, onReady) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index b7315d553..821fd396c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -39,26 +39,22 @@ class EventFactory { content: String, sig: String, ) = when (kind) { - AdvertisedRelayListEvent.KIND -> - AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) - AppRecommendationEvent.KIND -> - AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AppRecommendationEvent.KIND -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AppSpecificDataEvent.KIND -> AppSpecificDataEvent(id, pubKey, createdAt, tags, content, sig) AudioHeaderEvent.KIND -> AudioHeaderEvent(id, pubKey, createdAt, tags, content, sig) AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig) BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) - CalendarDateSlotEvent.KIND -> - CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) - CalendarTimeSlotEvent.KIND -> - CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) + CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) - ChannelHideMessageEvent.KIND -> - ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) + ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) @@ -79,23 +75,19 @@ class EventFactory { } ChatMessageRelayListEvent.KIND -> ChatMessageRelayListEvent(id, pubKey, createdAt, tags, content, sig) ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) - CommunityDefinitionEvent.KIND -> - CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) + CommunityDefinitionEvent.KIND -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) CommunityListEvent.KIND -> CommunityListEvent(id, pubKey, createdAt, tags, content, sig) - CommunityPostApprovalEvent.KIND -> - CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) + CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) DraftEvent.KIND -> DraftEvent(id, pubKey, createdAt, tags, content, sig) EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) - EmojiPackSelectionEvent.KIND -> - EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) + EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) - FileStorageHeaderEvent.KIND -> - FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FileStorageHeaderEvent.KIND -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) @@ -105,16 +97,12 @@ class EventFactory { GitRepositoryEvent.KIND -> GitRepositoryEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) - HTTPAuthorizationEvent.KIND -> - HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) - LiveActivitiesChatMessageEvent.KIND -> - LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentRequestEvent.KIND -> - LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentResponseEvent.KIND -> - LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)