diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cf9256dc47..7fe6b43bcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst -import android.content.ContentResolver import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory +import com.vitorpamplona.amethyst.ui.resourceCacheInit import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager @@ -111,29 +112,37 @@ class AppModules( // Blocking load of UI Preferences to avoid theme/language blinking val uiPrefs by lazy { + Log.d("AppModules", "UiSharedPreferences Init") UiSharedPreferences(appContext, applicationIOScope) } // Blocking load of Tor Settings to avoid connection leaks val torPrefs by lazy { + Log.d("AppModules", "TorSharedPreferences Init") TorSharedPreferences(appContext, applicationIOScope) } // Namecoin ElectrumX server preferences (global, like Tor settings) val namecoinPrefs by lazy { + Log.d("AppModules", "NamecoinSharedPreferences Init") NamecoinSharedPreferences(appContext, applicationIOScope) } // OTS blockchain explorer preferences (global, like Tor settings) val otsPrefs by lazy { + Log.d("AppModules", "OtsSharedPreferences Init") OtsSharedPreferences(appContext, applicationIOScope) } // App services that should be run as soon as there are subscribers to their flows - val locationManager = LocationState(appContext, applicationIOScope) + val locationManager by lazy { + Log.d("AppModules", "LocationManager Init") + LocationState(appContext, applicationIOScope) + } val connManager = ConnectivityManager(appContext, applicationIOScope) val uiState by lazy { + Log.d("AppModules", "UiSettingsState Init") UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope) } @@ -158,40 +167,67 @@ class AppModules( // Offers easy methods to know when connections are happening through Tor or not val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) - // Custom fetcher that considers tor settings and avoids forwarding. - val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) + val electrumXClient by lazy { + Log.d("AppModules", "ElectrumXClient Init") + val client = + ElectrumXClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) + applicationIOScope.launch { + try { + val pinnedCerts = namecoinPrefs.loadPinnedCerts() + if (pinnedCerts.isNotEmpty()) { + client.setDynamicCerts(pinnedCerts) + } + } catch (_: Exception) { + // Non-fatal — defaults will still work + } + } + client + } - val namecoinResolver = - NamecoinNameResolver( - electrumxClient = - ElectrumXClient( - socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, - ), - serverListProvider = { - // User-configured custom servers take priority - namecoinPrefs.customServersOrNull - ?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { - TOR_ELECTRUMX_SERVERS - } else { - DEFAULT_ELECTRUMX_SERVERS - } - }, - ) - val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) + val namecoinResolver by + lazy { + Log.d("AppModules", "Namecoin Resolver Init") + NamecoinNameResolver( + electrumxClient = electrumXClient, + serverListProvider = { + // User-configured custom servers take priority + namecoinPrefs.customServersOrNull + ?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + TOR_ELECTRUMX_SERVERS + } else { + DEFAULT_ELECTRUMX_SERVERS + } + }, + ) + } - // Application-wide block height request cache - val otsBlockHeightCache by lazy { OtsBlockHeightCache() } + val nip05Client by + lazy { + Log.d("AppModules", "NIP05Client Init") + Nip05Client( + fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05), + namecoinResolverBuilder = { namecoinResolver }, + ) + } - val otsResolverBuilder: TorAwareOkHttpOtsResolverBuilder = - TorAwareOkHttpOtsResolverBuilder( - roleBasedHttpClientBuilder::okHttpClientForMoney, - roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations, - otsBlockHeightCache, - customExplorerUrl = { otsPrefs.current.normalizedUrl() }, - ) + val otsResolverBuilder by + lazy { + Log.d("AppModules", "OtsResolverBuilder Init") + TorAwareOkHttpOtsResolverBuilder( + roleBasedHttpClientBuilder::okHttpClientForMoney, + roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations, + OtsBlockHeightCache(), + customExplorerUrl = { otsPrefs.current.normalizedUrl() }, + ) + } // Application-wide ots verification cache - val otsVerifCache by lazy { VerificationStateCache(otsResolverBuilder) } + val otsVerifCache by lazy { + Log.d("AppModules", "OtsCache Init") + VerificationStateCache(otsResolverBuilder) + } val torEvaluatorFlow = TorRelayState( @@ -243,7 +279,12 @@ class AppModules( val authCoordinator = AuthCoordinator(client, applicationIOScope) // Tries to verify new OTS events when they arrive. - val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationIOScope) + val otsEventVerifier = + IncomingOtsEventVerifier( + otsVerifCache = { otsVerifCache }, + cache = cache, + scope = applicationIOScope, + ) // Tracks if it is possible to connect to relays. val failureTracker = RelayOfflineTracker(client) @@ -269,10 +310,10 @@ class AppModules( // keeps all accounts live val accountsCache = AccountCacheState( - geolocationFlow = locationManager.geohashStateFlow, - nwcFilterAssembler = sources.nwc, - contentResolverFn = ::contentResolverFn, - otsResolverBuilder = otsResolverBuilder, + geolocationFlow = { locationManager.geohashStateFlow }, + nwcFilterAssembler = { sources.nwc }, + contentResolverFn = { appContext.contentResolver }, + otsResolverBuilder = { otsResolverBuilder.build() }, cache = cache, client = client, ) @@ -280,8 +321,8 @@ class AppModules( val sessionManager = AccountSessionManager( accountsCache = accountsCache, - nip05Client = nip05Client, - client = client, + nip05ClientBuilder = { nip05Client }, + clientBuilder = { client }, localPreferences = LocalPreferences, scope = applicationIOScope, ) @@ -307,7 +348,8 @@ class AppModules( } } - val blossomResolver = + val blossomResolver by lazy { + Log.d("AppModules", "BlossomServerResolver Init") BlossomServerResolver( loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) }, blossomServers = { addressesToSubscribe -> @@ -323,6 +365,7 @@ class AppModules( }, httpClientBuilder = roleBasedHttpClientBuilder, ) + } // Organizes cache clearing val trimmingService = MemoryTrimmingService(cache) @@ -332,36 +375,47 @@ class AppModules( val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationIOScope) // saves the .content of NIP-95 blobs in disk to save memory - val nip95cache: File by lazy { Nip95CacheFactory.new(appContext) } + val nip95cache: File by lazy { + Log.d("AppModules", "NIP95 Cache Init") + Nip95CacheFactory.new(appContext) + } // local video cache with disk + memory - val videoCache: VideoCache by lazy { VideoCacheFactory.new(appContext) } + val videoCache: VideoCache by lazy { + Log.d("AppModules", "VideoCache Init") + VideoCacheFactory.new(appContext) + } // image cache in disk for coil - val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(appContext) } + val diskCache: DiskCache by lazy { + Log.d("AppModules", "ImageCacheFactory Init") + ImageCacheFactory.newDisk(appContext) + } // image cache in memory for coil - val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(appContext) } + val memoryCache: MemoryCache by lazy { + Log.d("AppModules", "MemoryCache Init") + ImageCacheFactory.newMemory(appContext) + } // crash report storage - val crashReportCache: CrashReportCache by lazy { CrashReportCache(appContext) } + val crashReportCache = CrashReportCache(appContext) // cache for NIP-11 documents val nip11Cache: Nip11CachedRetriever by lazy { + Log.d("AppModules", "Nip11CachedRetriever Init") Nip11CachedRetriever(torEvaluatorFlow::okHttpClientForRelay) } - fun contentResolverFn(): ContentResolver = appContext.contentResolver - fun setImageLoader() { + Log.d("AppModules", "ImageLoaderSetup Init") ImageLoaderSetup.setup( app = appContext, diskCache = { diskCache }, memoryCache = { memoryCache }, - blossomServerResolver = blossomResolver, - ) { url -> - okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url)) - } + blossomServerResolver = { blossomResolver }, + callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, + ) } fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub) @@ -380,14 +434,20 @@ class AppModules( // initializes diskcache on an IO thread. applicationIOScope.launch { - // preloads tor preferences - torPrefs + // Sets Coil - Tor - OkHttp link + setImageLoader() } // initializes diskcache on an IO thread. applicationIOScope.launch { // Sets Coil - Tor - OkHttp link - setImageLoader() + uiState + } + + // LRUCache should not be instanciated in the Main thread due to blocking + applicationIOScope.launch { + CachedRobohash + resourceCacheInit() } // registers to receive events @@ -395,15 +455,10 @@ class AppModules( // initializes diskcache on an IO thread. applicationIOScope.launch { - // Sets Coil - Tor - OkHttp link - delay(3000) + // Prepares video cache later + delay(10_000) videoCache } - - applicationIOScope.launch { - // Eagerly initialize OtsSharedPreferences off the main thread - otsPrefs - } } fun terminate(appContext: Context) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 968a6e75c7..a61c4f3740 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,9 +25,31 @@ import android.content.Context import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit +import coil3.util.CoilUtils.result import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.UiSettings +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.defaultDiscoveryFollowList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.defaultFileServer +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.defaultHomeFollowList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.defaultNotificationFollowList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.defaultStoriesFollowList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.hasDonatedInVersion +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.hideBlockAlertDialog +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.hideDeleteRequestDialog +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestAppSpecificData +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestBlockedRelayList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestChannelList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestCommunityList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestContactList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestGeohashList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestHashtagList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestMuteList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestPrivateHomeRelayList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestSearchRelayList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestTrustedRelayList +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.latestUserMetadata +import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.localRelayServers import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -62,6 +84,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -448,96 +471,128 @@ object LocalPreferences { Log.d("LocalPreferences", "Load account from file $npub") val result = withContext(Dispatchers.IO) { - checkNotInMainThread() - return@withContext with(encryptedPreferences(npub)) { + Log.d("LocalPreferences", "Load account from file $npub - opened file") val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null - val externalSignerPackageName = - getString(PrefKeys.SIGNER_PACKAGE_NAME, null) - ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null + val externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null - val defaultHomeFollowList = parseOrNull(PrefKeys.DEFAULT_HOME_FOLLOW_LIST) ?: TopFilter.AllFollows - val defaultStoriesFollowList = parseOrNull(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST) ?: TopFilter.Global - val defaultNotificationFollowList = parseOrNull(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST) ?: TopFilter.Global - val defaultDiscoveryFollowList = parseOrNull(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST) ?: TopFilter.Global + val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + + Log.d("LocalPreferences", "Load account from file $npub - keys ready") - val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) - val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) - - val pendingAttestations = parseOrNull>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf() - val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() - - val latestUserMetadata = parseEventOrNull(PrefKeys.LATEST_USER_METADATA) - val latestContactList = parseEventOrNull(PrefKeys.LATEST_CONTACT_LIST) - val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) - val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) - val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) - val latestIndexRelayList = parseEventOrNull(PrefKeys.LATEST_INDEX_RELAY_LIST) - val latestRelayFeedsList = parseEventOrNull(PrefKeys.LATEST_RELAY_FEEDS_LIST) - val latestBlockedRelayList = parseEventOrNull(PrefKeys.LATEST_BLOCKED_RELAY_LIST) - val latestTrustedRelayList = parseEventOrNull(PrefKeys.LATEST_TRUSTED_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 latestChannelList = parseEventOrNull(PrefKeys.LATEST_CHANNEL_LIST) - val latestCommunityList = parseEventOrNull(PrefKeys.LATEST_COMMUNITY_LIST) - val latestHashtagList = parseEventOrNull(PrefKeys.LATEST_HASHTAG_LIST) - val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) - val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) - val latestTrustProviderList = parseEventOrNull(PrefKeys.LATEST_TRUST_PROVIDER_LIST) - val latestPaymentTargets = parseEventOrNull(PrefKeys.LATEST_PAYMENT_TARGETS) - val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) + val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() + val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() + + val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) + val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) + val defaultNotificationFollowListStr = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) + val defaultDiscoveryFollowListStr = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) + val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) + val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null) + + val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) + val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null) + val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null) + val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null) + val latestNip65RelayListStr = getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null) + val latestSearchRelayListStr = getString(PrefKeys.LATEST_SEARCH_RELAY_LIST, null) + val latestIndexRelayListStr = getString(PrefKeys.LATEST_INDEX_RELAY_LIST, null) + val latestRelayFeedsListStr = getString(PrefKeys.LATEST_RELAY_FEEDS_LIST, null) + val latestBlockedRelayListStr = getString(PrefKeys.LATEST_BLOCKED_RELAY_LIST, null) + val latestTrustedRelayListStr = getString(PrefKeys.LATEST_TRUSTED_RELAY_LIST, null) + val latestMuteListStr = getString(PrefKeys.LATEST_MUTE_LIST, null) + val latestPrivateHomeRelayListStr = getString(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, null) + val latestAppSpecificDataStr = getString(PrefKeys.LATEST_APP_SPECIFIC_DATA, null) + val latestChannelListStr = getString(PrefKeys.LATEST_CHANNEL_LIST, null) + val latestCommunityListStr = getString(PrefKeys.LATEST_COMMUNITY_LIST, null) + val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null) + val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null) + val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null) + val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null) + val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null) + val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null) + + Log.d("LocalPreferences", "Load account from file $npub - before parsing events") + + val defaultHomeFollowList = async { parseOrNull(defaultHomeFollowListStr) ?: TopFilter.AllFollows } + val defaultStoriesFollowList = async { parseOrNull(defaultStoriesFollowListStr) ?: TopFilter.Global } + val defaultNotificationFollowList = async { parseOrNull(defaultNotificationFollowListStr) ?: TopFilter.Global } + val defaultDiscoveryFollowList = async { parseOrNull(defaultDiscoveryFollowListStr) ?: TopFilter.Global } + val zapPaymentRequestServer = async { parseOrNull(zapPaymentRequestServerStr) } + val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } + + val pendingAttestations = async { parseOrNull>(pendingAttestationsStr) ?: mapOf() } + val latestUserMetadata = async { parseEventOrNull(latestUserMetadataStr) } + val latestContactList = async { parseEventOrNull(latestContactListStr) } + val latestDmRelayList = async { parseEventOrNull(latestDmRelayListStr) } + val latestNip65RelayList = async { parseEventOrNull(latestNip65RelayListStr) } + val latestSearchRelayList = async { parseEventOrNull(latestSearchRelayListStr) } + val latestIndexRelayList = async { parseEventOrNull(latestIndexRelayListStr) } + val latestRelayFeedsList = async { parseEventOrNull(latestRelayFeedsListStr) } + val latestBlockedRelayList = async { parseEventOrNull(latestBlockedRelayListStr) } + val latestTrustedRelayList = async { parseEventOrNull(latestTrustedRelayListStr) } + val latestMuteList = async { parseEventOrNull(latestMuteListStr) } + val latestPrivateHomeRelayList = async { parseEventOrNull(latestPrivateHomeRelayListStr) } + val latestAppSpecificData = async { parseEventOrNull(latestAppSpecificDataStr) } + val latestChannelList = async { parseEventOrNull(latestChannelListStr) } + val latestCommunityList = async { parseEventOrNull(latestCommunityListStr) } + val latestHashtagList = async { parseEventOrNull(latestHashtagListStr) } + val latestGeohashList = async { parseEventOrNull(latestGeohashListStr) } + val latestEphemeralList = async { parseEventOrNull(latestEphemeralListStr) } + val latestTrustProviderList = async { parseEventOrNull(latestTrustProviderListStr) } + val latestPaymentTargets = async { parseEventOrNull(latestPaymentTargetsStr) } val lastReadPerRoute = - parseOrNull>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { - MutableStateFlow(it.value) - } ?: mapOf() + async { + parseOrNull>(lastReadPerRouteStr)?.mapValues { + MutableStateFlow(it.value) + } ?: mapOf() + } - val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) - val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() + Log.d("LocalPreferences", "Load account from file $npub - asyncs created") return@with AccountSettings( keyPair = keyPair, transientAccount = false, externalSignerPackageName = externalSignerPackageName, localRelayServers = MutableStateFlow(localRelayServers), - defaultFileServer = defaultFileServer, + defaultFileServer = defaultFileServer.await(), stripLocationOnUpload = stripLocationOnUpload, - defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), - defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), - defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), - defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer?.normalize()), + defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList.await()), + defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList.await()), + defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList.await()), + defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList.await()), + zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer.await()?.normalize()), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, - backupUserMetadata = latestUserMetadata, - backupContactList = latestContactList, - backupNIP65RelayList = latestNip65RelayList, - backupDMRelayList = latestDmRelayList, - backupSearchRelayList = latestSearchRelayList, - backupIndexRelayList = latestIndexRelayList, - backupRelayFeedsList = latestRelayFeedsList, - backupBlockedRelayList = latestBlockedRelayList, - backupTrustedRelayList = latestTrustedRelayList, - backupPrivateHomeRelayList = latestPrivateHomeRelayList, - backupMuteList = latestMuteList, - backupAppSpecificData = latestAppSpecificData, - backupChannelList = latestChannelList, - backupCommunityList = latestCommunityList, - backupHashtagList = latestHashtagList, - backupGeohashList = latestGeohashList, - backupEphemeralChatList = latestEphemeralList, - backupTrustProviderList = latestTrustProviderList, - lastReadPerRoute = MutableStateFlow(lastReadPerRoute), + backupUserMetadata = latestUserMetadata.await(), + backupContactList = latestContactList.await(), + backupNIP65RelayList = latestNip65RelayList.await(), + backupDMRelayList = latestDmRelayList.await(), + backupSearchRelayList = latestSearchRelayList.await(), + backupIndexRelayList = latestIndexRelayList.await(), + backupRelayFeedsList = latestRelayFeedsList.await(), + backupBlockedRelayList = latestBlockedRelayList.await(), + backupTrustedRelayList = latestTrustedRelayList.await(), + backupPrivateHomeRelayList = latestPrivateHomeRelayList.await(), + backupMuteList = latestMuteList.await(), + backupAppSpecificData = latestAppSpecificData.await(), + backupChannelList = latestChannelList.await(), + backupCommunityList = latestCommunityList.await(), + backupHashtagList = latestHashtagList.await(), + backupGeohashList = latestGeohashList.await(), + backupEphemeralChatList = latestEphemeralList.await(), + backupTrustProviderList = latestTrustProviderList.await(), + lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), - pendingAttestations = MutableStateFlow(pendingAttestations), - backupNipA3PaymentTargets = latestPaymentTargets, + pendingAttestations = MutableStateFlow(pendingAttestations.await()), + backupNipA3PaymentTargets = latestPaymentTargets.await(), ) } } @@ -545,8 +600,7 @@ object LocalPreferences { return result } - private inline fun SharedPreferences.parseOrNull(key: String): T? { - val value = getString(key, null) + private inline fun parseOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null } @@ -558,13 +612,12 @@ object LocalPreferences { } } catch (e: Throwable) { if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e) + Log.w("LocalPreferences", "Error Decoding ${T::class.java} from Preferences with value $value", e) null } } - private inline fun SharedPreferences.parseEventOrNull(key: String): T? { - val value = getString(key, null) + private inline fun parseEventOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null } @@ -572,7 +625,7 @@ object LocalPreferences { 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) + Log.w("LocalPreferences", "Error Decoding ${T::class.java} from Preferences with value $value", e) null } } 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 dbe16ea3d4..ba4d5d825f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip51Lists.PinListState import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState @@ -143,7 +144,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -235,9 +236,9 @@ import kotlin.coroutines.cancellation.CancellationException class Account( val settings: AccountSettings = AccountSettings(KeyPair()), override val signer: NostrSigner, - val geolocationFlow: StateFlow, - val nwcFilterAssembler: NWCPaymentFilterAssembler, - val otsResolverBuilder: OtsResolverBuilder, + val geolocationFlow: () -> StateFlow, + val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, + val otsResolverBuilder: () -> OtsResolver, val cache: LocalCache, val client: INostrClient, val scope: CoroutineScope, @@ -318,6 +319,7 @@ class Account( val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) + val pinState = PinListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) val appSpecific = AppSpecificState(signer, cache, scope, settings) @@ -1809,6 +1811,43 @@ class Account( cache.justConsumeMyOwnEvent(event) } + suspend fun addPin(note: Note) { + if (!isWriteable() || note.isDraft()) return + + sendMyPublicAndPrivateOutbox(pinState.addPin(note)) + } + + suspend fun removePin(note: Note) { + if (!isWriteable() || note.isDraft()) return + + val event = pinState.removePin(note) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) + } + } + + suspend fun createAddPinEvent(note: Note): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = pinState.addPin(note) + val relays = outboxRelays.flow.value + + return event to relays + } + + suspend fun createRemovePinEvent(note: Note): Pair>? { + if (!isWriteable() || note.isDraft()) return null + + val event = pinState.removePin(note) ?: return null + val relays = outboxRelays.flow.value + + return event to relays + } + + fun consumePinEvent(event: Event) { + cache.justConsumeMyOwnEvent(event) + } + suspend fun createAuthEvent( relay: NormalizedRelayUrl, challenge: String, 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 d792bcb3a2..61803f7055 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2491,7 +2491,7 @@ object LocalCache : ILocalCache, ICacheProvider { suspend fun findEarliestOtsForNote( note: Note, - otsVerifCache: VerificationStateCache, + otsVerifCacheBuilder: () -> VerificationStateCache, ): Long? { checkNotInMainThread() @@ -2502,7 +2502,7 @@ object LocalCache : ILocalCache, ICacheProvider { notes.mapNotNull { _, item -> val noteEvent = item.event if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) { - val cachedTime = (otsVerifCache.justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime + val cachedTime = (otsVerifCacheBuilder().justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime if (cachedTime != null) { if (minTime == null || cachedTime < (minTime ?: Long.MAX_VALUE)) { minTime = cachedTime @@ -2518,7 +2518,7 @@ object LocalCache : ILocalCache, ICacheProvider { } candidates.forEach { noteEvent -> - (otsVerifCache.cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime -> + (otsVerifCacheBuilder().cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime -> if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) { minTime = stampedTime } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 28e417e462..30379256b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log @@ -45,10 +45,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update class AccountCacheState( - val geolocationFlow: StateFlow, - val nwcFilterAssembler: NWCPaymentFilterAssembler, + val geolocationFlow: () -> StateFlow, + val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val contentResolverFn: () -> ContentResolver, - val otsResolverBuilder: OtsResolverBuilder, + val otsResolverBuilder: () -> OtsResolver, val cache: LocalCache, val client: INostrClient, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt index 3e3222b675..f082fa1086 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/IncomingOtsEventVerifier.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn class IncomingOtsEventVerifier( - private val otsVerifCache: VerificationStateCache, + private val otsVerifCache: () -> VerificationStateCache, private val cache: LocalCache, private val scope: CoroutineScope, ) { @@ -52,7 +52,7 @@ class IncomingOtsEventVerifier( suspend fun consume(note: Note) { note.event?.let { event -> if (event is OtsEvent) { - otsVerifCache.cacheVerify(event) + otsVerifCache().cacheVerify(event) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt index c8b537f985..423943d4dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope @@ -37,7 +37,7 @@ import java.util.Base64 class OtsState( val signer: NostrSigner, val cache: LocalCache, - val otsResolver: OtsResolverBuilder, + val otsResolver: () -> OtsResolver, val scope: CoroutineScope, val settings: AccountSettings, ) { @@ -58,7 +58,7 @@ class OtsState( Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") return settings.pendingAttestations.value.toList().mapNotNull { (key, value) -> - val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver.build()) + val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver()) if (otsState != null) { val hint = cache.getNoteIfExists(key)?.toEventHint() @@ -96,7 +96,7 @@ class OtsState( Base64.getEncoder().encodeToString( OtsEvent.stamp( id, - otsResolver.build(), + otsResolver(), ), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index bb40be6cfe..616ad24e6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -64,7 +64,7 @@ import kotlinx.coroutines.launch */ class NwcSignerState( val signer: NostrSigner, - val nwcFilterAssembler: NWCPaymentFilterAssembler, + val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val cache: LocalCache, val scope: CoroutineScope, val nip47Setup: MutableStateFlow, @@ -163,11 +163,13 @@ class NwcSignerState( relay = walletService.relayUri, ) - nwcFilterAssembler.subscribe(filter) + val assembler = nwcFilterAssembler() + + assembler.subscribe(filter) scope.launch(Dispatchers.IO) { delay(60000) - nwcFilterAssembler.unsubscribe(filter) + assembler.unsubscribe(filter) } cache.consume(event, null, true, walletService.relayUri) { @@ -204,11 +206,13 @@ class NwcSignerState( relay = walletService.relayUri, ) - nwcFilterAssembler.subscribe(filter) + val assembler = nwcFilterAssembler() + + assembler.subscribe(filter) scope.launch(Dispatchers.IO) { delay(60000) // waits 1 minute to complete payment. - nwcFilterAssembler.unsubscribe(filter) + assembler.unsubscribe(filter) } cache.consume(event, zappedNote, true, walletService.relayUri) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt new file mode 100644 index 0000000000..fbe7114088 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/PinListState.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 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.nip51Lists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +@Stable +class PinListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + val pinList = cache.getOrCreateAddressableNote(PinListEvent.createPinAddress(signer.pubKey)) + + fun getPinListFlow(): StateFlow = pinList.flow().metadata.stateFlow + + fun getPinList(): PinListEvent? = pinList.event as? PinListEvent + + fun pinnedEvents(note: Note): List { + val noteEvent = note.event as? PinListEvent + return noteEvent?.pinnedEvents() ?: emptyList() + } + + @OptIn(FlowPreview::class) + val pinnedNotes: StateFlow> = + getPinListFlow() + .map { noteState -> + pinnedEvents(noteState.note) + }.onStart { + emit(pinnedEvents(pinList)) + }.debounce(100) + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + val pinnedEventIdSet: StateFlow> = + pinnedNotes + .map { pins -> + pins.map { it.eventId }.toSet() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(FlowPreview::class) + val pinnedNotesList: StateFlow> = + pinnedNotes + .map { pins -> + pins.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed() + }.onStart { + emit( + pinnedNotes.value.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed(), + ) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun isPinned(note: Note): Boolean = pinnedEventIdSet.value.contains(note.idHex) + + suspend fun addPin(note: Note): PinListEvent { + val currentList = getPinList() + val pin = EventBookmark(note.idHex, note.relayHintUrl()) + + return if (currentList == null) { + PinListEvent.create( + pin = pin, + signer = signer, + ) + } else { + PinListEvent.add( + earlierVersion = currentList, + pin = pin, + signer = signer, + ) + } + } + + suspend fun removePin(note: Note): PinListEvent? { + val currentList = getPinList() ?: return null + val pin = EventBookmark(note.idHex, note.relayHintUrl()) + + return PinListEvent.remove( + earlierVersion = currentList, + pin = pin, + signer = signer, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 600cecaa98..b6bab2374f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.coroutines.cancellation.CancellationException @@ -58,17 +58,21 @@ class NamecoinSharedPreferences( companion object { val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts") } /** * Current settings, loaded synchronously at init to avoid races. */ - private val _settings = - MutableStateFlow( - runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT }, - ) + private val _settings = MutableStateFlow(NamecoinSettings.DEFAULT) val settings: StateFlow = _settings + init { + scope.launch { + _settings.tryEmit(loadFromDisk() ?: NamecoinSettings.DEFAULT) + } + } + /** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */ val current: NamecoinSettings get() = _settings.value @@ -99,8 +103,48 @@ class NamecoinSharedPreferences( suspend fun reset() { persist(NamecoinSettings.DEFAULT) + clearPinnedCerts() } + /** + * Store a PEM-encoded certificate that the user accepted via Test Connection. + * The cert is appended to the existing list and synced to the ElectrumXClient. + */ + suspend fun addPinnedCert(pem: String) { + val existing = loadPinnedCertsFromDisk() + val updated = (existing + pem).distinct() + savePinnedCerts(updated) + } + + /** Load all user-pinned certs from disk (for startup sync). */ + suspend fun loadPinnedCerts(): List = loadPinnedCertsFromDisk() + + private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList()) + + private suspend fun savePinnedCerts(certs: List) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_PINNED_CERTS] = json.encodeToString(certs) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error writing pinned certs: ${e.message}") + } + } + + private suspend fun loadPinnedCertsFromDisk(): List = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val certsJson = prefs[KEY_PINNED_CERTS] + if (certsJson != null) { + json.decodeFromString>(certsJson) + } else { + emptyList() + } + } catch (_: Exception) { + emptyList() + } + // ── Internal ─────────────────────────────────────────────────────── private suspend fun persist(settings: NamecoinSettings) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 44d62f84ae..8dae90a9b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -51,7 +51,7 @@ class FeedTopNavFilterState( val feedFilterListName: MutableStateFlow, val kind3Follows: StateFlow, val allFollows: StateFlow, - val locationFlow: StateFlow, + val locationFlow: () -> StateFlow, val followsRelays: StateFlow>, val blockedRelays: StateFlow>, val proxyRelays: StateFlow>, @@ -78,7 +78,7 @@ class FeedTopNavFilterState( } TopFilter.AroundMe -> { - AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) + AroundMeFeedFlow(locationFlow(), followsRelays, proxyRelays) } TopFilter.Chess -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt index 1c3d62232c..c65d10d6fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/broadcast/BroadcastTracker.kt @@ -142,55 +142,58 @@ class BroadcastTracker { } } - client.subscribe(subscription) + try { + client.subscribe(subscription) - val finalBroadcast = - coroutineScope { - val resultCollector = - async { - val receivedRelays = mutableSetOf() - var currentBroadcast = broadcast + val finalBroadcast = + coroutineScope { + val resultCollector = + async { + val receivedRelays = mutableSetOf() + var currentBroadcast = broadcast - withTimeoutOrNull(TIMEOUT_SECONDS * 1000) { - while (receivedRelays.size < relays.size) { - val response = resultChannel.receive() + withTimeoutOrNull(TIMEOUT_SECONDS * 1000) { + while (receivedRelays.size < relays.size) { + val response = resultChannel.receive() - // Skip if already received (don't override success) - if (response.relay in receivedRelays) continue + // Skip if already received (don't override success) + if (response.relay in receivedRelays) continue - receivedRelays.add(response.relay) - currentBroadcast = currentBroadcast.withResult(response.relay, response.result) + receivedRelays.add(response.relay) + currentBroadcast = currentBroadcast.withResult(response.relay, response.result) - // Update active broadcasts with new progress - _activeBroadcasts.update { list -> - list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList() + // Update active broadcasts with new progress + _activeBroadcasts.update { list -> + list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList() + } } } + + // Mark remaining relays as timeout + relays.filter { it !in receivedRelays }.forEach { relay -> + currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout) + } + + currentBroadcast } - // Mark remaining relays as timeout - relays.filter { it !in receivedRelays }.forEach { relay -> - currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout) - } + // Send after setting up listener + client.send(event, relays) - currentBroadcast - } + resultCollector.await() + } - // Send after setting up listener - client.send(event, relays) + resultChannel.close() - resultCollector.await() + // Remove from active, emit to completed + _activeBroadcasts.update { list -> + list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList() } - client.unsubscribe(subscription) - resultChannel.close() - - // Remove from active, emit to completed - _activeBroadcasts.update { list -> - list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList() + Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") + } finally { + client.unsubscribe(subscription) } - - Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success") } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt index 2b0b804475..7406c8e136 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt @@ -41,12 +41,12 @@ import kotlin.coroutines.cancellation.CancellationException class BlossomFetcher( private val options: Options, private val data: Uri, - private val blossomServerResolver: BlossomServerResolver, + private val blossomServerResolver: () -> BlossomServerResolver, private val networkFetcher: (url: String) -> Fetcher, ) : Fetcher { override suspend fun fetch(): FetchResult? = try { - val urlResult = blossomServerResolver.findServers(data.toString()) + val urlResult = blossomServerResolver().findServers(data.toString()) networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch() } catch (e: Exception) { if (e is CancellationException) throw e @@ -55,7 +55,7 @@ class BlossomFetcher( @OptIn(ExperimentalCoilApi::class) class Factory( - val blossomServerResolver: BlossomServerResolver, + val blossomServerResolver: () -> BlossomServerResolver, val networkClient: (url: String) -> Call.Factory, ) : Fetcher.Factory { private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 22a71cd21a..9e8b40a035 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -64,7 +64,7 @@ class ImageLoaderSetup { app: Context, diskCache: () -> DiskCache, memoryCache: () -> MemoryCache, - blossomServerResolver: BlossomServerResolver, + blossomServerResolver: () -> BlossomServerResolver, callFactory: (url: String) -> Call.Factory, ) { SingletonImageLoader.setUnsafe( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index c72cd8fe96..d3a8d15d83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -65,7 +65,7 @@ class LocationState( } @OptIn(ExperimentalCoroutinesApi::class) - val geohashStateFlow = + val geohashStateFlow by lazy { hasLocationPermission .transformLatest { if (it) { @@ -92,4 +92,5 @@ class LocationState( SharingStarted.WhileSubscribed(5000), latestLocation, ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt index 4855b493f8..1a859848c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -66,7 +66,7 @@ data class NamecoinSettings( * TLS is the default protocol. Append `:tcp` for plaintext * (useful for `.onion` addresses and local servers). * - * `.onion` addresses automatically get `trustAllCerts = true` + * `.onion` addresses automatically get `usePinnedTrustStore = true` * since certificate verification is meaningless over Tor. */ fun parseServerString(s: String): ElectrumxServer? { @@ -77,11 +77,15 @@ data class NamecoinSettings( if (host.isEmpty() || port <= 0 || port > 65535) return null val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" val isOnion = host.endsWith(".onion") + // All custom servers use the pinned trust store. ElectrumX + // servers almost universally use self-signed certs, so we + // route them through our pinned SSLSocketFactory (hardcoded + // defaults + TOFU-pinned certs + system CAs). return ElectrumxServer( host = host, port = port, useSsl = useSsl, - trustAllCerts = isOnion || !useSsl, + usePinnedTrustStore = true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index cc8ebc0b89..ed78455dbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -31,6 +32,7 @@ val ReportsAndBookmarksFromKeyKinds = listOf( ReportEvent.KIND, BookmarkListEvent.KIND, + PinListEvent.KIND, ) fun filterBookmarksAndReportsFromKey( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 34381f2de2..d656913e74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import kotlinx.collections.immutable.ImmutableList @@ -276,6 +277,30 @@ fun observeUserBookmarkCount( return flow.collectAsStateWithLifecycle(0) } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserPinnedNotesCount( + user: User, + accountViewModel: AccountViewModel, +): State { + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + val flow = + remember(user) { + accountViewModel + .pinnedNotes(user) + .flow() + .metadata.stateFlow + .sample(200) + .mapLatest { noteState -> + (noteState.note.event as? PinListEvent)?.countPins() ?: 0 + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle(0) +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserIsFollowing( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt index 3e801e8940..66330ef818 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/StringResourceCache.kt @@ -39,6 +39,12 @@ private var resourceCacheLanguage: String? = null // Caches most common icons in the app to avoid using disk private val iconCache = LruCache>(30) +fun resourceCacheInit() { + resourceCache + resourceCacheLanguage + iconCache +} + fun checkLanguage(currentLanguage: String) { if (resourceCacheLanguage == null) { resourceCacheLanguage = currentLanguage diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 53cd5a131a..4219ce80d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -119,7 +119,7 @@ open class EditPostViewModel : ViewModel() { this.editedFromNote = edit this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05Client) + this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) } fun sendPost() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 23a5e561dc..aef785e87d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -109,7 +109,12 @@ class BlossomServersViewModel : ViewModel() { serverUrl: String, ) { viewModelScope.launch { - val serverName = name.ifBlank { Rfc3986.host(serverUrl) } + val serverName = + name.ifBlank { + runCatching { + Rfc3986.host(serverUrl) + }.getOrNull() + } ?: serverUrl _fileServers.update { it.minus( ServerName(serverName, serverUrl, ServerType.Blossom), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt index dad9a09f87..c08b113738 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/ToastManager.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.components.toasts +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.toasts.multiline.MultiErrorToastMsg import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage import kotlinx.coroutines.flow.MutableStateFlow +@Stable class ToastManager { val toasts = MutableStateFlow(null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 461c2ab8f0..7a309e730d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -35,13 +35,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri import androidx.core.util.Consumer -import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages @@ -145,230 +141,226 @@ fun AppNavigation( ) { val nav = rememberNav() - val navBackStackEntry by nav.controller.currentBackStackEntryAsState() - val isTabPagerRoute = - navBackStackEntry?.destination?.let { dest -> - dest.hasRoute() || dest.hasRoute() - } ?: false - val drawerGesturesEnabled = - !isTabPagerRoute || - nav.drawerState.isOpen || - nav.drawerState.targetValue != nav.drawerState.currentValue - - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) { - NavHost( - navController = nav.controller, - startDestination = Route.Home, - enterTransition = { fadeIn(animationSpec = tween(200)) }, - exitTransition = { fadeOut(animationSpec = tween(200)) }, - ) { - composable { HomeScreen(accountViewModel, nav) } - composable { MessagesScreen(accountViewModel, nav) } - composable { VideoScreen(accountViewModel, nav) } - composable { DiscoverScreen(accountViewModel, nav) } - composable { NotificationScreen(accountViewModel, nav) } - composable { ChessLobbyScreen(accountViewModel, nav) } - - composableFromEnd { WalletScreen(accountViewModel, nav) } - composableFromEnd { WalletSendScreen(accountViewModel, nav) } - composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } - composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } - - composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } - composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } - composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } - - composableFromBottomArgs { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } - - composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } - composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } - composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } - composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - - composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } - - composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } - - composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } - composable { SearchScreen(accountViewModel, nav) } - - composableFromEnd { AllSettingsScreen(accountViewModel, nav) } - composableFromEnd { AccountBackupScreen(accountViewModel, nav) } - composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } - composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } - composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } - composableFromEnd { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } - composableFromEnd { BookmarkListScreen(accountViewModel, nav) } - composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } - composableFromEnd { DraftListScreen(accountViewModel, nav) } - composableFromEnd { SettingsScreen(accountViewModel, nav) } - composableFromEnd { UserSettingsScreen(accountViewModel, nav) } - composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } - composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } - composableFromEndArgs { - ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) - } - - composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } - composableFromEnd { EventSyncScreen(accountViewModel, nav) } - composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } - composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } - - composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } - composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } - composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } - composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } - composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } - composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } - composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - - composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } - composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } - - composableFromEndArgs { - PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) - } - - composableFromEndArgs { - LiveActivityChannelScreen( - Address(it.kind, it.pubKeyHex, it.dTag), - draftId = it.draftId, - replyToId = it.replyTo, - accountViewModel, - nav, - ) - } - - composableFromEndArgs { - EphemeralChatScreen( - id = it.id, - relayUrl = it.relayUrl, - draftId = it.draftId, - replyToId = it.replyTo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } - composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } - composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } - - composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } - - composableFromBottomArgs { - GeoHashPostScreen( - geohash = it.geohash, - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - replyId = it.replyTo, - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - NewPublicMessageScreen( - to = it.toKey(), - replyId = it.replyId, - draftId = it.draftId, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { - HashtagPostScreen( - hashtag = it.hashtag, - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - replyId = it.replyTo, - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - ReplyCommentPostScreen( - replyId = it.replyTo, - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - NewProductScreen( - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - quoteId = it.quote, - draftId = it.draft, - accountViewModel, - nav, - ) - } - - composableFromBottomArgs { - LongFormPostScreen( - draftId = it.draft, - versionId = it.version, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { - ShortNotePostScreen( - message = it.message, - attachment = it.attachment?.ifBlank { null }?.toUri(), - baseReplyToId = it.baseReplyTo, - quoteId = it.quote, - forkId = it.fork, - versionId = it.version, - draftId = it.draft, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - composableFromBottomArgs { - VoiceReplyScreen( - replyToNoteId = it.replyToNoteId, - recordingFilePath = it.recordingFilePath, - mimeType = it.mimeType, - duration = it.duration, - amplitudesJson = it.amplitudes, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { + BuildNavigation(accountViewModel, nav) } - NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) - DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) } +@Composable +fun BuildNavigation( + accountViewModel: AccountViewModel, + nav: Nav, +) { + NavHost( + navController = nav.controller, + startDestination = Route.Home, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) }, + ) { + composable { HomeScreen(accountViewModel, nav) } + composable { MessagesScreen(accountViewModel, nav) } + composable { VideoScreen(accountViewModel, nav) } + composable { DiscoverScreen(accountViewModel, nav) } + composable { NotificationScreen(accountViewModel, nav) } + composable { ChessLobbyScreen(accountViewModel, nav) } + + composableFromEnd { WalletScreen(accountViewModel, nav) } + composableFromEnd { WalletSendScreen(accountViewModel, nav) } + composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } + composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + + composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } + composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } + composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } + + composableFromBottomArgs { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } + + composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } + composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } + composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } + composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + + composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } + + composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } + + composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } + composable { SearchScreen(accountViewModel, nav) } + + composableFromEnd { AllSettingsScreen(accountViewModel, nav) } + composableFromEnd { AccountBackupScreen(accountViewModel, nav) } + composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } + composableFromEnd { PrivacyOptionsScreen(nav) } + composableFromEnd { NamecoinSettingsScreen(nav) } + composableFromEnd { OtsSettingsScreen(nav) } + composableFromEnd { BookmarkListScreen(accountViewModel, nav) } + composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } + composableFromEnd { DraftListScreen(accountViewModel, nav) } + composableFromEnd { SettingsScreen(accountViewModel, nav) } + composableFromEnd { UserSettingsScreen(accountViewModel, nav) } + composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } + composableFromEndArgs { + ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) + } + + composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + composableFromEnd { EventSyncScreen(accountViewModel, nav) } + composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } + + composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } + composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } + composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } + composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } + composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + + composableFromEndArgs { + PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) + } + + composableFromEndArgs { + LiveActivityChannelScreen( + Address(it.kind, it.pubKeyHex, it.dTag), + draftId = it.draftId, + replyToId = it.replyTo, + accountViewModel, + nav, + ) + } + + composableFromEndArgs { + EphemeralChatScreen( + id = it.id, + relayUrl = it.relayUrl, + draftId = it.draftId, + replyToId = it.replyTo, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } + composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } + + composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } + + composableFromBottomArgs { + GeoHashPostScreen( + geohash = it.geohash, + message = it.message, + attachment = it.attachment, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + NewPublicMessageScreen( + to = it.toKey(), + replyId = it.replyId, + draftId = it.draftId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { + HashtagPostScreen( + hashtag = it.hashtag, + message = it.message, + attachment = it.attachment, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + ReplyCommentPostScreen( + replyId = it.replyTo, + message = it.message, + attachment = it.attachment, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + NewProductScreen( + message = it.message, + attachment = it.attachment, + quoteId = it.quote, + draftId = it.draft, + accountViewModel, + nav, + ) + } + + composableFromBottomArgs { + LongFormPostScreen( + draftId = it.draft, + versionId = it.version, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { + ShortNotePostScreen( + message = it.message, + attachment = it.attachment, + baseReplyToId = it.baseReplyTo, + quoteId = it.quote, + forkId = it.fork, + versionId = it.version, + draftId = it.draft, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromBottomArgs { + VoiceReplyScreen( + replyToNoteId = it.replyToNoteId, + recordingFilePath = it.recordingFilePath, + mimeType = it.mimeType, + duration = it.duration, + amplitudesJson = it.amplitudes, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + @Composable private fun NavigateIfIntentRequested( nav: Nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 269e223084..a9ff23ec8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -20,18 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.navigation.drawer -import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -39,6 +45,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -67,16 +74,18 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -112,8 +121,11 @@ import com.vitorpamplona.amethyst.ui.theme.IconRowModifier import com.vitorpamplona.amethyst.ui.theme.IconRowTextModifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier +import com.vitorpamplona.amethyst.ui.theme.Size22ModifierWith4Padding +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.TextStyleBottomNavBar import com.vitorpamplona.amethyst.ui.theme.Width16Space import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing @@ -123,6 +135,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlin.text.ifEmpty @Composable fun DrawerContent( @@ -224,9 +237,9 @@ fun ProfileContentTemplate( modifier = bannerModifier, ) } else { - Image( - painter = painterRes(R.drawable.profile_banner, 3), - contentDescription = stringRes(R.string.profile_banner), + AsyncImage( + model = R.drawable.profile_banner, + contentDescription = stringResource(R.string.profile_banner), contentScale = ContentScale.FillWidth, modifier = bannerModifier, ) @@ -275,30 +288,108 @@ private fun EditStatusBoxes( val statuses by observeUserStatuses(baseAccountUser, accountViewModel) if (statuses.isEmpty()) { - StatusEditBar(accountViewModel = accountViewModel, nav = nav) + PreviewStatusEditBar(accountViewModel = accountViewModel, nav = nav) } else { statuses.forEach { val noteStatus by observeNote(it, accountViewModel) - StatusEditBar(noteStatus.note.event?.content, it.address, accountViewModel, nav) + PreviewStatusEditBar(noteStatus.note.event?.content, it.address, accountViewModel, nav) } } } +@Composable +fun PreviewStatusEditBar( + savedStatus: String? = null, + address: Address? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + var isEditing by remember { mutableStateOf(false) } + + if (isEditing) { + StatusEditBar(savedStatus, address, onDone = { isEditing = false }, accountViewModel, nav) + } else { + FakeEditBar(savedStatus) { isEditing = true } + } +} + +@Composable +fun FakeEditBar( + savedStatus: String? = null, + onEdit: () -> Unit, +) { + // ── Static text styled to look like OutlinedTextField ─── + Box( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onEdit, + ).padding(top = 8.dp), + ) { + // Outer border — matches OutlinedTextField's unfocused border + Box( + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) // same as OutlinedTextField + .border( + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = savedStatus?.ifEmpty { null } ?: stringRes(R.string.status_update), + style = MaterialTheme.typography.bodyLarge, + color = + if (savedStatus?.ifEmpty { null } == null) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + // Floating label — sits on top of the border like Material does + Text( + text = stringRes(R.string.status_update), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .padding(start = 12.dp) + .align(Alignment.TopStart) + .offset(y = (-8).dp) // float above the border + .background(MaterialTheme.colorScheme.surface) // punch through border line + .padding(horizontal = 4.dp), + ) + } +} + @Composable fun StatusEditBar( savedStatus: String? = null, address: Address? = null, + onDone: () -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } val currentStatus = remember { mutableStateOf(savedStatus ?: "") } - val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } } + LaunchedEffect(nav.drawerState.isClosed) { if (nav.drawerState.isClosed) { focusManager.clearFocus(true) + onDone() + } else { + focusRequester.requestFocus() } } @@ -306,7 +397,7 @@ fun StatusEditBar( value = currentStatus.value, onValueChange = { currentStatus.value = it }, label = { Text(text = stringRes(R.string.status_update)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), placeholder = { Text( text = stringRes(R.string.status_update), @@ -332,8 +423,11 @@ fun StatusEditBar( ), singleLine = true, trailingIcon = { + val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } } if (hasChanged.value) { - SendButton { + SendButton( + tint = MaterialTheme.colorScheme.primary, + ) { if (address == null) { accountViewModel.createStatus(currentStatus.value) } else { @@ -354,7 +448,10 @@ fun StatusEditBar( } @Composable -fun SendButton(onClick: () -> Unit) { +fun SendButton( + tint: Color = MaterialTheme.colorScheme.placeholderText, + onClick: () -> Unit, +) { IconButton( modifier = Size26Modifier, onClick = onClick, @@ -363,7 +460,7 @@ fun SendButton(onClick: () -> Unit) { imageVector = Icons.AutoMirrored.Filled.Send, null, modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, + tint = tint, ) } } @@ -440,7 +537,9 @@ fun ListContent( icon = Icons.Default.AccountCircle, tint = MaterialTheme.colorScheme.primary, nav = nav, - route = remember { Route.Profile(accountViewModel.userProfile().pubkeyHex) }, + computeRoute = { + Route.Profile(accountViewModel.userProfile().pubkeyHex) + }, ) NavigationRow( @@ -577,6 +676,25 @@ fun NavigationRow( ) } +@Composable +fun NavigationRow( + title: Int, + icon: ImageVector, + tint: Color, + nav: INav, + computeRoute: () -> Route, +) { + IconRow( + title = title, + icon = icon, + tint = tint, + onClick = { + nav.closeDrawer() + nav.nav(computeRoute) + }, + ) +} + @Composable fun IconRow( title: Int, @@ -585,30 +703,26 @@ fun IconRow( tint: Color, onClick: () -> Unit, ) { + val title = stringRes(title) + Row( modifier = - Modifier - .fillMaxWidth() - .clickable( - onClick = onClick, - ), + IconRowModifier.clickable( + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = IconRowModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterRes(icon, iconReference), - contentDescription = stringRes(title), - modifier = Size22Modifier, - tint = tint, - ) - Text( - modifier = IconRowTextModifier, - text = stringRes(title), - fontSize = Font18SP, - ) - } + Icon( + painter = painterRes(icon, iconReference), + contentDescription = title, + modifier = Size22Modifier, + tint = tint, + ) + Text( + modifier = IconRowTextModifier, + text = title, + fontSize = Font18SP, + ) } } @@ -619,32 +733,28 @@ fun IconRow( tint: Color, onClick: () -> Unit, ) { + val title = stringRes(title) + Row( modifier = - Modifier - .fillMaxWidth() - .clickable( - onClickLabel = stringRes(title), - onClick = onClick, - ), + IconRowModifier.clickable( + onClickLabel = title, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = IconRowModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = stringRes(title), - modifier = Size22Modifier.padding(end = 4.dp), - tint = tint, - ) + Icon( + imageVector = icon, + contentDescription = title, + modifier = Size22ModifierWith4Padding, + tint = tint, + ) - Text( - modifier = IconRowTextModifier, - text = stringRes(title), - fontSize = Font18SP, - ) - } + Text( + modifier = IconRowTextModifier, + text = title, + fontSize = Font18SP, + ) } } @@ -723,6 +833,7 @@ fun BottomContent( .fillMaxWidth() .padding(horizontal = 15.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween, ) { val string = remember { @@ -730,18 +841,16 @@ fun BottomContent( withLink( LinkAnnotation.Clickable( "clickable", - TextLinkStyles( - SpanStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ), - ), + TextStyleBottomNavBar, ) { nav.nav(Route.Note(BuildConfig.RELEASE_NOTES_ID)) nav.closeDrawer() }, ) { - append("v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase()) + append("v") + append(BuildConfig.VERSION_NAME) + append("-") + append(BuildConfig.FLAVOR.uppercase()) } } } @@ -752,7 +861,7 @@ fun BottomContent( overflow = TextOverflow.Ellipsis, maxLines = 1, ) - Box(modifier = Modifier.weight(1F)) + IconButton( onClick = { nav.nav(Route.QRDisplay(user.pubkeyHex)) @@ -762,7 +871,7 @@ fun BottomContent( Icon( painter = painterRes(R.drawable.ic_qrcode, 2), contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), - modifier = Modifier.size(24.dp), + modifier = Size24Modifier, tint = MaterialTheme.colorScheme.primary, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 36939b594a..28e04659bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -123,7 +123,7 @@ fun LoadOts( withContext(Dispatchers.IO) { LocalCache.findEarliestOtsForNote( note = noteStatus?.note ?: note, - otsVerifCache = Amethyst.instance.otsVerifCache, + otsVerifCacheBuilder = { Amethyst.instance.otsVerifCache }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 85f1e54e01..325005dd8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -134,7 +134,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine( if (nip05VerifState.isExpired()) { LaunchedEffect(key1 = nip05VerifState) { accountViewModel.runOnIO { - nip05State.checkAndUpdate(accountViewModel.nip05Client) + nip05State.checkAndUpdate(accountViewModel.nip05ClientBuilder) } } } @@ -442,7 +442,7 @@ fun ObserveAndRenderNIP05VerifiedSymbol( if (state.isExpired()) { LaunchedEffect(key1 = state) { accountViewModel.runOnIO { - nip05State.checkAndUpdate(accountViewModel.nip05Client) + nip05State.checkAndUpdate(accountViewModel.nip05ClientBuilder) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4fc579daa9..4adc385d54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -82,20 +82,27 @@ class UserSuggestionState( .map(::userSearchTermOrNull) .map { prefix -> if (prefix != null) { - if (prefix.contains('@')) { + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (prefix.contains('@')) { + Nip05Id.parse(prefix) + } else if (prefix.endsWith(".bit", ignoreCase = true)) { + Nip05Id("_", prefix.lowercase()) + } else { + null + } + if (nip05 != null) { runCatching { - Nip05Id.parse(prefix)?.let { nip05 -> - nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - user } + user } }.getOrNull() } else if (prefix.startsWithAny(userUriPrefixes)) { 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 574b56771a..f54253a992 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 @@ -34,6 +34,7 @@ import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.PersonRemove import androidx.compose.material.icons.outlined.PlaylistAdd +import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.Report import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Share @@ -110,6 +111,7 @@ data class DropDownParams( val isFollowingAuthor: Boolean, val isPrivateBookmarkNote: Boolean, val isPublicBookmarkNote: Boolean, + val isPinnedNote: Boolean, val isLoggedUser: Boolean, val isSensitive: Boolean, val showSensitiveContent: Boolean?, @@ -130,6 +132,7 @@ fun NoteDropDownMenu( isFollowingAuthor = false, isPrivateBookmarkNote = false, isPublicBookmarkNote = false, + isPinnedNote = false, isLoggedUser = false, isSensitive = false, showSensitiveContent = null, @@ -265,6 +268,19 @@ fun NoteDropDownMenu( onDismiss() } } + if (state.isLoggedUser) { + if (state.isPinnedNote) { + M3ActionRow(icon = Icons.Outlined.PushPin, text = stringRes(R.string.unpin_from_profile)) { + accountViewModel.removePin(note) + onDismiss() + } + } else { + M3ActionRow(icon = Icons.Outlined.PushPin, text = stringRes(R.string.pin_to_profile)) { + accountViewModel.addPin(note) + onDismiss() + } + } + } val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { if (note.event is LongTextNoteEvent) { @@ -329,12 +345,14 @@ fun observeBookmarksFollowsAndAccount( combine( accountViewModel.account.kind3FollowList.flow, accountViewModel.account.bookmarkState.bookmarks, + accountViewModel.account.pinState.pinnedEventIdSet, accountViewModel.showSensitiveContent(), - ) { follows, bookmarks, showSensitiveContent -> + ) { follows, bookmarks, pinnedIds, showSensitiveContent -> DropDownParams( isFollowingAuthor = note.author?.pubkeyHex in follows.authors, isPrivateBookmarkNote = note in bookmarks.private, isPublicBookmarkNote = note in bookmarks.public, + isPinnedNote = note.idHex in pinnedIds, isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitiveOrNSFW() ?: false, showSensitiveContent = showSensitiveContent, @@ -345,6 +363,7 @@ fun observeBookmarksFollowsAndAccount( isFollowingAuthor = note.author?.pubkeyHex?.let { accountViewModel.account.isFollowing(it) } ?: false, isPrivateBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.private, isPublicBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.public, + isPinnedNote = accountViewModel.account.pinState.isPinned(note), isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitiveOrNSFW() ?: false, showSensitiveContent = accountViewModel.showSensitiveContent().value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index b150efe393..da55dd4665 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -209,7 +209,7 @@ open class CommentPostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 3bee7dfe07..3c866565f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -49,6 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog @@ -103,7 +103,7 @@ import kotlinx.coroutines.withContext fun ReplyCommentPostScreen( replyId: HexKey? = null, message: String? = null, - attachment: Uri? = null, + attachment: String? = null, quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, @@ -127,7 +127,7 @@ fun ReplyCommentPostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt index 92d0b18ecf..105a7c898a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PinList.kt @@ -45,15 +45,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.ShowMoreButton -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.PinIcon import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent @OptIn(ExperimentalLayoutApi::class) @@ -66,7 +65,7 @@ fun RenderPinListEvent( ) { val noteEvent = baseNote.event as? PinListEvent ?: return - val pins by remember { mutableStateOf(noteEvent.pins()) } + val pins by remember { mutableStateOf(noteEvent.pinnedEvents()) } var expanded by remember { mutableStateOf(false) } @@ -78,7 +77,7 @@ fun RenderPinListEvent( } Text( - text = "#${noteEvent.dTag()}", + text = "Pinned Notes", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -103,15 +102,10 @@ fun RenderPinListEvent( Spacer(modifier = Modifier.width(5.dp)) - TranslatableRichTextViewer( - content = pin, - canPreview = true, - quotesLeft = 1, - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = baseNote.idHex, - accountViewModel = accountViewModel, - nav = nav, + Text( + text = NEvent.create(pin.eventId, pin.author, null, pin.relay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 9e9fcf4b32..4bd3202fd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -93,8 +93,8 @@ sealed class AccountState { @Stable class AccountSessionManager( val accountsCache: AccountCacheState, - val nip05Client: Nip05Client, - val client: INostrClient, + val nip05ClientBuilder: () -> Nip05Client, + val clientBuilder: () -> INostrClient, val localPreferences: LocalPreferences, val scope: CoroutineScope, ) { @@ -227,7 +227,7 @@ class AccountSessionManager( onError("Could not parse nip05 address: $nip05") } else { try { - val pubkeyInfo = nip05Client.get(nip05) + val pubkeyInfo = nip05ClientBuilder().get(nip05) if (pubkeyInfo == null) { onError("User not found in the nip05 server: $nip05") } else { @@ -287,6 +287,8 @@ class AccountSessionManager( val toPost = accountSettings.backupNIP65RelayList?.writeRelaysNorm()?.toSet() ?: DefaultNIP65RelaySet + val client = clientBuilder() + accountSettings.backupUserMetadata?.let { client.send(it, toPost) } accountSettings.backupContactList?.let { client.send(it, toPost) } accountSettings.backupNIP65RelayList?.let { client.send(it, toPost) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 8dc7d01f91..246beb2fc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -37,9 +37,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalConfiguration +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.currentBackStackEntryAsState import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent -import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import kotlinx.coroutines.launch @@ -48,8 +51,7 @@ import kotlinx.coroutines.launch fun AccountSwitcherAndLeftDrawerLayout( accountViewModel: AccountViewModel, accountSessionManager: AccountSessionManager, - nav: INav, - gesturesEnabled: Boolean = true, + nav: Nav, content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() @@ -82,9 +84,19 @@ fun AccountSwitcherAndLeftDrawerLayout( } } + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val isTabPagerRoute = + navBackStackEntry?.destination?.let { dest -> + dest.hasRoute() || dest.hasRoute() + } ?: false + val drawerGesturesEnabled = + !isTabPagerRoute || + nav.drawerState.isOpen || + nav.drawerState.targetValue != nav.drawerState.currentValue + ModalNavigationDrawer( drawerState = nav.drawerState, - gesturesEnabled = gesturesEnabled, + gesturesEnabled = drawerGesturesEnabled, drawerContent = { DrawerContent(nav, openSheetFunction, accountViewModel) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) 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 59351fc050..f0a8218694 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 @@ -112,6 +112,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -128,6 +129,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -172,7 +174,7 @@ class AccountViewModel( val torSettings: TorSettingsFlow, val dataSources: RelaySubscriptionsCoordinator, val httpClientBuilder: IRoleBasedHttpClientBuilder, - val nip05Client: INip05Client, + val nip05ClientBuilder: () -> INip05Client, ) : ViewModel(), Dao { var firstRoute: Route? = null @@ -376,7 +378,7 @@ class AccountViewModel( if (currentReactions.isNotEmpty()) { account.delete(currentReactions) } else { - if (settings.isCompleteUIMode()) { + if (settings.isCompleteUIMode() && note.event !is NIP17Group) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> broadcastTracker.trackBroadcast( @@ -845,6 +847,42 @@ class AccountViewModel( fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) + fun pinnedNotes(user: User): Note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex)) + + fun addPin(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createAddPinEvent(note)?.let { (event, relays) -> + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumePinEvent(event) + } + } + } else { + launchSigner { account.addPin(note) } + } + } + + fun removePin(note: Note) { + if (settings.isCompleteUIMode()) { + launchSigner { + account.createRemovePinEvent(note)?.let { (event, relays) -> + broadcastTracker.trackBroadcast( + event = event, + relays = relays, + client = account.client, + ) + account.consumePinEvent(event) + } + } + } else { + launchSigner { account.removePin(note) } + } + } + fun addPrivateBookmark(note: Note) { if (settings.isCompleteUIMode()) { launchSigner { @@ -1289,7 +1327,7 @@ class AccountViewModel( val torSettings: TorSettingsFlow, val dataSources: RelaySubscriptionsCoordinator, val okHttpClient: RoleBasedHttpClientBuilder, - val nip05Client: Nip05Client, + val nip05ClientBuilder: () -> Nip05Client, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = @@ -1299,12 +1337,12 @@ class AccountViewModel( torSettings, dataSources, okHttpClient, - nip05Client, + nip05ClientBuilder, ) as T } init { - Log.d("Init", "AccountViewModel") + Log.d("AccountViewModel", "Init") viewModelScope.launch(Dispatchers.IO) { feedStates.init() // awaits for init to finish before starting to capture new events. @@ -1325,7 +1363,7 @@ class AccountViewModel( } override fun onCleared() { - Log.d("Init", "AccountViewModel onCleared") + Log.d("AccountViewModel", "onCleared") feedStates.destroy() super.onCleared() } @@ -1807,9 +1845,9 @@ fun mockAccountViewModel(): AccountViewModel { Account( settings = AccountSettings(keyPair), signer = NostrSignerInternal(keyPair), - geolocationFlow = MutableStateFlow(LocationState.LocationResult.Loading), - nwcFilterAssembler = nwcFilters, - otsResolverBuilder = EmptyOtsResolverBuilder, + geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, + nwcFilterAssembler = { nwcFilters }, + otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = client, scope = scope, @@ -1821,7 +1859,7 @@ fun mockAccountViewModel(): AccountViewModel { torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), - nip05Client = EmptyNip05Client(), + nip05ClientBuilder = { EmptyNip05Client() }, ).also { mockedCache = it } @@ -1858,9 +1896,9 @@ fun mockVitorAccountViewModel(): AccountViewModel { Account( settings = AccountSettings(keyPair), signer = NostrSignerInternal(keyPair), - geolocationFlow = MutableStateFlow(LocationState.LocationResult.Loading), - nwcFilterAssembler = nwcFilters, - otsResolverBuilder = EmptyOtsResolverBuilder, + geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, + nwcFilterAssembler = { nwcFilters }, + otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = EmptyNostrClient(), scope = scope, @@ -1872,7 +1910,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), - nip05Client = EmptyNip05Client(), + nip05ClientBuilder = { EmptyNip05Client() }, ).also { vitorCache = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index d1f44cea25..2fef9419eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -72,7 +72,7 @@ fun LoggedInPage( torSettings = Amethyst.instance.torPrefs.value, dataSources = Amethyst.instance.sources, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder, - nip05Client = Amethyst.instance.nip05Client, + nip05ClientBuilder = { Amethyst.instance.nip05Client }, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index 4ccee13ed8..9ac4ab6628 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -27,8 +27,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,6 +36,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -46,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.PinnedNotesFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -67,15 +69,28 @@ fun BookmarkListScreen( factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) + val pinnedNotesFeedViewModel: PinnedNotesFeedViewModel = + viewModel( + key = "NostrPinnedNotesFeedViewModel", + factory = PinnedNotesFeedViewModel.Factory(accountViewModel.account), + ) + val bookmarkState by accountViewModel.account.bookmarkState.bookmarks .collectAsStateWithLifecycle(null) + val pinState by accountViewModel.account.pinState.pinnedNotesList + .collectAsStateWithLifecycle(null) + LaunchedEffect(bookmarkState) { publicFeedViewModel.invalidateData() privateFeedViewModel.invalidateData() } - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav) + LaunchedEffect(pinState) { + pinnedNotesFeedViewModel.invalidateData() + } + + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, pinnedNotesFeedViewModel, accountViewModel, nav) } @Composable @@ -83,10 +98,11 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + pinnedNotesFeedViewModel: PinnedNotesFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 2 } + val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() DisappearingScaffold( @@ -94,10 +110,11 @@ private fun RenderBookmarkScreen( topBar = { Column { TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack) - TabRow( + ScrollableTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, + edgePadding = 8.dp, modifier = TabRowHeight, ) { Tab( @@ -110,6 +127,11 @@ private fun RenderBookmarkScreen( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, text = { Text(text = stringRes(R.string.public_bookmarks)) }, ) + Tab( + selected = pagerState.currentPage == 2, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + text = { Text(text = stringRes(R.string.pinned_notes)) }, + ) } } }, @@ -135,6 +157,15 @@ private fun RenderBookmarkScreen( nav = nav, ) } + + 2 -> { + RefresheableFeedView( + pinnedNotesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt new file mode 100644 index 0000000000..b20c5492ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedFilter.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 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.ui.screen.loggedIn.bookmarkgroups.default.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +class PinnedNotesFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.pinState.pinnedNotesList.value + .hashCode() + .toString() + + override fun feed(): List = account.pinState.pinnedNotesList.value +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.kt new file mode 100644 index 0000000000..ec3a434664 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/PinnedNotesFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 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.ui.screen.loggedIn.bookmarkgroups.default.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class PinnedNotesFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(PinnedNotesFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = PinnedNotesFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 73febbbf78..2d26603708 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -125,6 +125,7 @@ fun BookmarkGroupItemOptionsMenu( isFollowingAuthor = false, isPrivateBookmarkNote = false, isPublicBookmarkNote = false, + isPinnedNote = false, isLoggedUser = false, isSensitive = false, showSensitiveContent = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 19e917bd73..13130e854f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -260,7 +260,7 @@ class ChatNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 489a744f88..5b293355b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -186,7 +186,7 @@ open class ChannelNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 49f23cee10..ddbcdc910f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -211,7 +211,7 @@ class LongFormPostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 5f39bccc99..ef92875275 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column @@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog @@ -92,7 +92,7 @@ import kotlinx.coroutines.withContext @Composable fun NewProductScreen( message: String? = null, - attachment: Uri? = null, + attachment: String? = null, quoteId: HexKey? = null, draftId: HexKey? = null, accountViewModel: AccountViewModel, @@ -114,7 +114,7 @@ fun NewProductScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 2908f5a793..826fa4eaa4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -200,7 +200,7 @@ open class NewProductViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index 8eb16a3965..7eb071ecc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @@ -41,7 +41,7 @@ import kotlinx.coroutines.withContext fun GeoHashPostScreen( geohash: String? = null, message: String? = null, - attachment: Uri? = null, + attachment: String? = null, replyId: HexKey? = null, quoteId: HexKey? = null, draftId: HexKey? = null, @@ -69,7 +69,7 @@ fun GeoHashPostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index c547347d18..46aad80cf4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav @@ -41,7 +41,7 @@ import kotlinx.coroutines.withContext fun HashtagPostScreen( hashtag: String? = null, message: String? = null, - attachment: Uri? = null, + attachment: String? = null, replyId: HexKey? = null, quoteId: HexKey? = null, draftId: HexKey? = null, @@ -69,7 +69,7 @@ fun HashtagPostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 3f0e0d79d1..b0c5890587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -126,7 +127,7 @@ import kotlinx.coroutines.withContext @Composable fun ShortNotePostScreen( message: String? = null, - attachment: Uri? = null, + attachment: String? = null, baseReplyToId: HexKey? = null, quoteId: HexKey? = null, forkId: HexKey? = null, @@ -151,7 +152,7 @@ fun ShortNotePostScreen( message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) } - attachment?.let { + attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { val mediaType = context.contentResolver.getType(it) postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType))) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index fc68d621d9..e9f106d47e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -301,7 +301,7 @@ open class ShortNotePostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index 03db90149c..d68148af4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -72,7 +72,7 @@ class PeopleListViewModel : ViewModel() { ) { if (!this::account.isInitialized || this.account != accountVM.account) { this.account = accountVM.account - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) } this.selectedDTag.tryEmit(selectedDTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt index eab070de4c..9324e8cdf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -72,7 +72,7 @@ class FollowPackViewModel : ViewModel() { ) { if (!this::account.isInitialized || this.account != accountVM.account) { this.account = accountVM.account - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) } this.selectedDTag.tryEmit(selectedDTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt index 9b0c9286c6..03b156ecfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt @@ -116,7 +116,7 @@ fun ImportFollowListSelectUserScreen( ) { val viewModel: ImportFollowListSelectUserViewModel = viewModel( - factory = ImportFollowListSelectUserViewModel.Factory(accountViewModel.account, accountViewModel.nip05Client), + factory = ImportFollowListSelectUserViewModel.Factory(accountViewModel.account, accountViewModel.nip05ClientBuilder()), ) Scaffold( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 35e94f93fd..21f567ebb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -209,7 +209,7 @@ class NewPublicMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index 5a21fcb121..df8be05871 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar @@ -40,6 +41,12 @@ import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacyOptionsScreen(nav: INav) { + PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 0fd7328768..90565778b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -87,6 +87,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualCon import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.PinnedNotesTabHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.TabPinnedNotes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader @@ -223,6 +226,16 @@ fun PrepareViewModels( ), ) + val pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserProfilePinnedNotesFeedViewModel", + factory = + UserProfilePinnedNotesFeedViewModel.Factory( + baseUser, + accountViewModel.account, + ), + ) + val reportsFeedViewModel: UserProfileReportFeedViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel", @@ -244,6 +257,7 @@ fun PrepareViewModels( externalIdentities, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel = accountViewModel, @@ -263,6 +277,7 @@ fun ProfileScreen( externalIdentities: UserExternalIdentitiesViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -273,6 +288,7 @@ fun ProfileScreen( WatchLifecycleAndUpdateModel(mutualViewModel) WatchLifecycleAndUpdateModel(appRecommendations) WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) + WatchLifecycleAndUpdateModel(pinnedNotesFeedViewModel) WatchLifecycleAndUpdateModel(galleryFeedViewModel) UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) @@ -291,6 +307,7 @@ fun ProfileScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -385,12 +402,13 @@ private fun RenderScreen( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 11 } + val pagerState = rememberPagerState { 12 } Column { ProfileHeader(baseUser, appRecommendations, externalIdentities, nav, accountViewModel) @@ -412,6 +430,7 @@ private fun RenderScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -431,6 +450,7 @@ private fun RenderScreen( followersFeedViewModel, zapFeedViewModel, bookmarksFeedViewModel, + pinnedNotesFeedViewModel, galleryFeedViewModel, reportsFeedViewModel, accountViewModel, @@ -451,6 +471,7 @@ private fun CreateAndRenderPages( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -472,9 +493,10 @@ private fun CreateAndRenderPages( 5 -> TabFollowers(followersFeedViewModel, accountViewModel, nav) 6 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) 7 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav) - 8 -> TabFollowedTags(baseUser, accountViewModel, nav) - 9 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) - 10 -> TabRelays(baseUser, accountViewModel, nav) + 8 -> TabPinnedNotes(pinnedNotesFeedViewModel, accountViewModel, nav) + 9 -> TabFollowedTags(baseUser, accountViewModel, nav) + 10 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav) + 11 -> TabRelays(baseUser, accountViewModel, nav) } } @@ -504,6 +526,7 @@ private fun CreateAndRenderTabs( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, + pinnedNotesFeedViewModel: UserProfilePinnedNotesFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, @@ -520,6 +543,7 @@ private fun CreateAndRenderTabs( { FollowersTabHeader(baseUser, followersFeedViewModel, accountViewModel) }, { ZapTabHeader(zapFeedViewModel, accountViewModel) }, { BookmarkTabHeader(baseUser, accountViewModel) }, + { PinnedNotesTabHeader(baseUser, accountViewModel) }, { FollowedTagsTabHeader(baseUser, accountViewModel) }, { ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) }, { RelaysTabHeader(baseUser, accountViewModel) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index b7c317ac8a..7783b9de9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendatio val UserProfileListKinds = listOf( BookmarkListEvent.KIND, + PinListEvent.KIND, PeopleListEvent.KIND, FollowListEvent.KIND, HashtagListEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt new file mode 100644 index 0000000000..bddbc283a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/PinnedNotesTabHeader.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 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.ui.screen.loggedIn.profile.pinnedNotes + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPinnedNotesCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun PinnedNotesTabHeader( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val count by observeUserPinnedNotesCount(baseUser, accountViewModel) + + Text(text = "$count ${stringRes(R.string.pinned_notes)}") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt new file mode 100644 index 0000000000..82b52432d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/TabPinnedNotes.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 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.ui.screen.loggedIn.profile.pinnedNotes + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel + +@Composable +fun TabPinnedNotes( + feedViewModel: UserProfilePinnedNotesFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + LaunchedEffect(Unit) { feedViewModel.invalidateData() } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp), + ) { + RefresheableFeedView( + feedViewModel, + null, + enablePullRefresh = false, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt new file mode 100644 index 0000000000..8f2bddb8c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedFilter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 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.ui.screen.loggedIn.profile.pinnedNotes.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nip51Lists.PinListEvent + +class UserProfilePinnedNotesFeedFilter( + val user: User, + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex + + override fun feed(): List { + val note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex)) + val noteEvent = note.event as? PinListEvent ?: return emptyList() + + return noteEvent + .pinnedEvents() + .mapNotNull { + LocalCache.checkGetOrCreateNote(it.eventId) + }.reversed() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt new file mode 100644 index 0000000000..ac14c8e65c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/pinnedNotes/dal/UserProfilePinnedNotesFeedViewModel.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 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.ui.screen.loggedIn.profile.pinnedNotes.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class UserProfilePinnedNotesFeedViewModel( + val user: User, + val account: Account, +) : AndroidFeedViewModel(UserProfilePinnedNotesFeedFilter(user, account)) { + class Factory( + val user: User, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserProfilePinnedNotesFeedViewModel(user, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index d29bb70375..02045f7052 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -95,20 +95,28 @@ class SearchBarViewModel( searchTerm .debounce(400) .mapLatest { term -> - if (term.contains('@')) { + // NIP-05 resolution: user@domain or bare .bit domain + val nip05 = + if (term.contains('@')) { + Nip05Id.parse(term) + } else if (term.endsWith(".bit", ignoreCase = true)) { + // Bare .bit domain → synthesize _@domain.bit + Nip05Id("_", term.lowercase()) + } else { + null + } + if (nip05 != null) { runCatching { - Nip05Id.parse(term)?.let { nip05 -> - nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) } } - user } + user } }.getOrNull() } else if (term.startsWithAny(userUriPrefixes)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 68d952c017..2dffe5d4c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -91,7 +91,7 @@ fun SearchScreen( factory = SearchBarViewModel.Factory( accountViewModel.account, - accountViewModel.nip05Client, + accountViewModel.nip05ClientBuilder(), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 8705701947..0fdbd7be81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -28,25 +28,38 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NamecoinSettingsScreen(nav: INav) { + NamecoinSettingsScreen( + Amethyst.instance.namecoinPrefs, + electrumXClient = { Amethyst.instance.electrumXClient }, + nav, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun NamecoinSettingsScreen( namecoinPrefs: NamecoinSharedPreferences, + electrumXClient: () -> ElectrumXClient, nav: INav, ) { - val namecoinSettings by namecoinPrefs.settings.collectAsState() + val namecoinSettings by namecoinPrefs.settings.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() Scaffold( @@ -75,6 +88,13 @@ fun NamecoinSettingsScreen( onReset = { scope.launch { namecoinPrefs.reset() } }, + onTestServer = { server -> electrumXClient().testServer(server) }, + onPinCert = { pem -> + scope.launch { + namecoinPrefs.addPinnedCert(pem) + electrumXClient().addPinnedCert(pem) + } + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index db747e138d..5db51d692b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -41,6 +42,11 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,20 +59,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Complete settings section for Namecoin ElectrumX server configuration. @@ -79,6 +94,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S * @param onAddServer Called with `host:port[:tcp]` when user adds a server * @param onRemoveServer Called with the server string to remove * @param onReset Called when user resets to defaults + * @param onTestServer Suspend function to test a single server + * @param onPinCert Called with PEM string to persist a TOFU-pinned cert */ @Composable fun NamecoinSettingsSection( @@ -87,6 +104,8 @@ fun NamecoinSettingsSection( onAddServer: (String) -> Unit, onRemoveServer: (String) -> Unit, onReset: () -> Unit, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Column(modifier = modifier.padding(16.dp)) { @@ -150,12 +169,366 @@ fun NamecoinSettingsSection( } } } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(16.dp)) + + // ── Test Connection ──────────────────────────────── + TestConnectionSection( + settings = settings, + onTestServer = onTestServer, + onPinCert = onPinCert, + ) } } } } -// ── Sub-composables ──────────────────────────────────────────────────── +// ── Test Connection ──────────────────────────────────────────────────── + +/** + * Holds a cert pending user confirmation before pinning (TOFU). + */ +private data class PendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + +@Composable +private fun TestConnectionSection( + settings: NamecoinSettings, + onTestServer: suspend (ElectrumxServer) -> ServerTestResult, + onPinCert: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var isTesting by remember { mutableStateOf(false) } + var testResults by remember { mutableStateOf>(emptyList()) } + var lastTestTimestamp by remember { mutableStateOf(null) } + // Certs discovered during testing that need user confirmation + var pendingCerts by remember { mutableStateOf>(emptyList()) } + // Which cert is currently shown in the confirmation dialog + var confirmingCert by remember { mutableStateOf(null) } + + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + + // ── Cert confirmation dialog ─────────────────────────────── + confirmingCert?.let { pending -> + AlertDialog( + onDismissRequest = { + // Remove from pending list and move to next (or close) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }, + title = { Text(stringResource(R.string.namecoin_pin_cert_title)) }, + text = { + Column { + Text( + stringResource(R.string.namecoin_pin_cert_body, pending.serverHost), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pending.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pending.pem) + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_accept)) + } + }, + dismissButton = { + TextButton(onClick = { + pendingCerts = pendingCerts.drop(1) + confirmingCert = pendingCerts.firstOrNull() + }) { + Text(stringResource(R.string.namecoin_pin_cert_reject)) + } + }, + ) + } + + Column { + // ── Test button ──────────────────────────────────────── + Button( + onClick = { + if (!isTesting) { + isTesting = true + testResults = emptyList() + pendingCerts = emptyList() + scope.launch { + val results = mutableListOf() + val newCerts = mutableListOf() + for (server in servers) { + val result = onTestServer(server) + results.add(result) + testResults = results.toList() + // Collect certs for user confirmation (not auto-pinned) + val pem = result.serverCertPem + val fp = result.certFingerprint + if (result.success && pem != null && fp != null) { + newCerts.add( + PendingCertPin( + serverHost = "${server.host}:${server.port}", + fingerprint = fp, + pem = pem, + ), + ) + } + } + lastTestTimestamp = System.currentTimeMillis() + isTesting = false + // Show confirmation dialog for each new cert + if (newCerts.isNotEmpty()) { + pendingCerts = newCerts + confirmingCert = newCerts.first() + } + } + } + }, + enabled = !isTesting, + modifier = Modifier.fillMaxWidth(), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.namecoin_testing)) + } else { + Text(stringResource(R.string.namecoin_test_connection)) + } + } + + // ── Per-server results ───────────────────────────────── + if (testResults.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + + Text( + stringResource(R.string.namecoin_test_results), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(6.dp)) + + testResults.forEach { result -> + ServerTestResultRow(result) + } + + if (isTesting && testResults.size < servers.size) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Testing next server…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // ── Diagnostic card ──────────────────────────────────── + if (testResults.isNotEmpty() || lastTestTimestamp != null) { + Spacer(Modifier.height(16.dp)) + DiagnosticCard( + testResults = testResults, + lastTestTimestamp = lastTestTimestamp, + ) + } + } +} + +@Composable +private fun ServerTestResultRow(result: ServerTestResult) { + val serverLabel = "${result.server.host}:${result.server.port}" + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = if (result.success) "✅" else "❌", + fontSize = 14.sp, + modifier = Modifier.padding(end = 6.dp, top = 1.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = serverLabel, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.namecoin_response_time, result.responseTimeMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (result.success) { + Text( + text = stringResource(R.string.namecoin_test_success), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF2E8B57), + ) + val fp = result.certFingerprint + if (fp != null) { + Text( + text = "Cert: ${fp.take(23)}…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + val errorText = result.error + if (errorText != null) { + Text( + text = errorText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ── Diagnostic Card ──────────────────────────────────────────────────── + +@Composable +private fun DiagnosticCard( + testResults: List, + lastTestTimestamp: Long?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + stringResource(R.string.namecoin_diagnostics), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + + // Last test timestamp + if (lastTestTimestamp != null) { + val formatted = + remember(lastTestTimestamp) { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + .format(Date(lastTestTimestamp)) + } + val successCount = testResults.count { it.success } + val totalCount = testResults.size + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = "$formatted ($successCount/$totalCount OK)", + ) + } else { + DiagnosticRow( + label = stringResource(R.string.namecoin_last_test), + value = stringResource(R.string.namecoin_no_test_yet), + ) + } + + Spacer(Modifier.height(4.dp)) + + // Device info + DiagnosticRow( + label = stringResource(R.string.namecoin_device_info), + value = "${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + ) + + Spacer(Modifier.height(4.dp)) + + // TLS info from test results + val tlsVersions = + testResults + .mapNotNull { it.tlsVersion } + .distinct() + val tlsDisplay = + if (tlsVersions.isNotEmpty()) { + tlsVersions.joinToString(", ") + } else { + "—" + } + DiagnosticRow( + label = stringResource(R.string.namecoin_tls_info), + value = tlsDisplay, + ) + } + } +} + +@Composable +private fun DiagnosticRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.35f), + ) + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(0.65f), + ) + } +} + +// ── Original Sub-composables ─────────────────────────────────────────── @Composable private fun SectionHeader( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt index 8bbf6d743e..2d33dddee3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -42,6 +43,12 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OtsSettingsScreen(nav: INav) { + OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun OtsSettingsScreen( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index f51773ef66..bce51df82f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -48,9 +48,13 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize val Shapes = @@ -389,3 +393,13 @@ val SuggestionListDefaultHeightChat = Modifier.heightIn(0.dp, 200.dp) val SuggestionListDefaultHeightPage = Modifier.heightIn(0.dp, 300.dp) val FollowPackHeaderModifier = Modifier.fillMaxWidth().height(TopBarSize) + +val Size22ModifierWith4Padding = Modifier.size(22.dp).padding(end = 4.dp) + +val TextStyleBottomNavBar = + TextLinkStyles( + SpanStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ), + ) diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg deleted file mode 100644 index 126d9edf6d..0000000000 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg and /dev/null differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.webp b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.webp new file mode 100644 index 0000000000..0b80b67a15 Binary files /dev/null and b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.webp differ diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 7775e36281..0594b4b505 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -61,6 +61,8 @@ Podepisovatel neautorizoval dešifrování potřebné k provedení této operace. Aktivujte dešifrování NIP-44 ve své aplikaci pro podepisování a zkuste to znovu Podepisovatel nenalezen Byla aplikace pro podepisování odinstalována? Zkontrolujte, zda je aplikace nainstalována a obsahuje tento účet. Odhlaste se a přihlaste znovu, pokud se aplikace změnila. + Podepisovatel se choval neočekávaně + Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli. Zapy Počet zobrazení Zvýšení @@ -446,6 +448,8 @@ Maximální zaps Konsensus (0–100)% + Jedna možnost + Více možností Datum a čas ukončení ankety Anketa končí za %1$s Uzavřít po @@ -485,6 +489,9 @@ Příjemce a veřejnost neví, kdo platbu poslal Né Zap Žádná stopa v Nostr, pouze v Lightning + Anonymní + Odeslat jako novou jednorázovou identitu. Váš účet nebude s touto odpovědí spojen. + Tato odpověď bude odeslána z nové anonymní identity Souborový server Zvolte server pro nahrání tohoto souboru LnAddress nebo @Uživatel @@ -1324,6 +1331,7 @@ Hashtagy Komunity Seznamy + Relé Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1560,6 +1568,7 @@ Krátká videa Hlasová zpráva Hlasová odpověď + Webová záložka Wiki Začněte se skvělým feedem tím, že budete sledovat stejné lidi jako někdo, komu důvěřujete. Importovat seznam sledovaných @@ -1675,4 +1684,20 @@ Vždy Poslední synchronizace: %1$s Od poslední synchronizace + Webové záložky + Zatím žádné webové záložky. Klepněte na + pro přidání. + Přidat webovou záložku + Upravit webovou záložku + URL + https://example.com + Název + Název záložky + Popis + Krátký popis + Tagy (oddělené čárkou) + nostr, tech, blog + Uložit + Smazat + Smazat tuto webovou záložku? + Otevřít URL diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index fcf5a470e9..d34c599a99 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -61,6 +61,8 @@ Der Signierer hat die erforderliche Entschlüsselung nicht autorisiert. Aktiviere NIP-44-Entschlüsselung in deiner Signierer-App und versuche es erneut Signierer nicht gefunden Wurde die Signierer-App deinstalliert? Überprüfe, ob sie installiert ist und dieses Konto enthält. Melde dich ab und wieder an, falls sich die App geändert hat. + Signierer hat sich unerwartet verhalten + Externer Signierer hat Daten zurückgegeben, die für die Anfrage ungewöhnlich sind. Es könnte ein Fehler in Amethyst oder im Signierer vorliegen. Zaps Aufrufe Boost @@ -452,6 +454,8 @@ anz der Bedingungen ist erforderlich Maximaler Zap-Betrag Konsens (0–100)% + Einzelauswahl + Mehrfachauswahl Schließdatum und -zeit Umfrage endet in %1$s Schließen nach @@ -491,6 +495,9 @@ anz der Bedingungen ist erforderlich Empfänger und die Öffentlichkeit wissen nicht, wer die Zahlung gesendet hat Keine Zap Keine Spur in Nostr, nur in Lightning + Anonym + Als neue Wegwerfidentität posten. Dein Konto wird nicht mit dieser Antwort verknüpft. + Diese Antwort wird von einer neuen anonymen Identität veröffentlicht Dateiserver Wählen Sie einen Server zum Hochladen dieser Datei LnAddress oder @Benutzer @@ -1329,6 +1336,7 @@ anz der Bedingungen ist erforderlich Hash-Tags Gemeinschaften Listen + Relais Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1565,6 +1573,7 @@ anz der Bedingungen ist erforderlich Shorts Sprachnachricht Sprachantwort + Web-Lesezeichen Wiki Starte mit einem großartigen Feed, indem du dieselben Personen folgst wie jemand, dem du vertraust. Folgeliste importieren @@ -1680,4 +1689,20 @@ anz der Bedingungen ist erforderlich Gesamter Zeitraum Letzte Synchronisierung: %1$s Seit letzter Synchronisierung + Web-Lesezeichen + Noch keine Web-Lesezeichen. Tippe auf + um eines hinzuzufügen. + Web-Lesezeichen hinzufügen + Web-Lesezeichen bearbeiten + URL + https://beispiel.com + Titel + Lesezeichen-Titel + Beschreibung + Eine kurze Beschreibung + Tags (kommagetrennt) + nostr, tech, blog + Speichern + Löschen + Dieses Web-Lesezeichen löschen? + URL öffnen diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 3d7172fb04..8da7d139cc 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -61,6 +61,8 @@ Az aláíró nem engedélyezte a művelet végrehajtásához szükséges visszafejtést. Aktiválja az NIP-44 visszafejtést az aláíró alkalmazásban, és próbálja meg újra Nem található az aláíró Az aláíró alkalmazás el lett távolítva? Ellenőrizze, hogy az aláíró telepítve van-e és rendelkezik-e ezzel a fiókkal. Jelentkezzen ki és jelentkezzen be újra, ha az aláíró alkalmazás megváltozott. + Az aláíró hibásan működött + A külső aláíró a kéréstől eltérő, szokatlan választ küldött. Lehet, hogy hiba lépett fel az Amethystben vagy az aláíró alkalmazásban. Zap-ek Megtekintések száma Megtolás @@ -241,7 +243,7 @@ Már van Nostr-fiókja? Új fiók létrehozása Új kulcs előállítása - Hírfolyam betöltése… + Hírfolyam betöltése Fiók betöltése… "Hiba a válaszok betöltésekor: " Próbálja újra @@ -395,7 +397,7 @@ Bejegyzések megtekintése Cikkek megtekintése Hivakozások megtekintése - Hashtagek megtekintése + Kulcsszavak megtekintése Még nincs egyetlen könyvjelzőlistája sem. Koppintson az „Új” gombra, hogy létrehozzon egyet. Privát bejegyzések Privát bejegyzések (%1$s) @@ -489,6 +491,9 @@ A kedvezményezett és a nyilvánosság nem tudja, hogy ki küldte a fizetést Nem Zap Nostr-ban nyoma sincs, csak a Lightning-ban + Névtelen + Közzététel új, eldobható profillal. Az Ön saját fiókja nem lesz köthető ehhez a válaszhoz. + Ez a válasz egy új, névtelen profilból lesz közzétéve Fájlkiszolgáló Válasszon ki egy kiszolgálót a fájl feltöltéséhez neki: Ln-cím vagy @Felhasználó @@ -522,8 +527,8 @@ Igen Nem Követési lista - Követettek bejegyzései - Összes követett felhasználó + Minden ami követett + Minden követett felhasználó Alapértelmezett követési lista Követés proxyn keresztül Közelben lévők bejegyzései @@ -838,8 +843,8 @@ Hozzáadja a helyszínének geokivonatát a bejegyzéséhez. A nyilvánosság tudni fogja, hogy a jelenlegi helytől 5 km-en (3 mi) belül tertózkodik Helyszín-alapú bejegyzés Csak a helyszín követői láthatják. Az általános követők nem fogják látni. - Hashtag-exkluzív bejegyzés - Csak a hashtag követői fogják látni, de az Ön általános követői viszont nem. + Kulcsszó-exkluzív bejegyzés + Csak a kulcsszó követői fogják látni, de az Ön általános követői viszont nem. Helyszín betöltése… A helyszín-meghatározás nincs engedélyezve Hozzáadja az érzékeny tartalomra vonatkozó figyelmeztetést a tartalom megjelenítése előtt. Ez ideális bármilyen NSFW tartalom vagy olyan tartalom esetén, amelyet egyesek sértőnek vagy zavarónak találhatnak @@ -1183,7 +1188,7 @@ Átjátszók a kimenő üzenetkhez Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. - Adjon meg 1–3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért + Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Átjátszók a bejövő üzenetkhez Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához @@ -1328,9 +1333,10 @@ Felhasználók Szempont kiválasztása a hírfolyam szűréséhez Hírfolyamok - Hashtagek + Kulcsszavak Közösségek Listák + Átjátszók Kijelentkeztetés az eszköz zárolása esetén Privát üzenet Nyílvános üzenet @@ -1341,7 +1347,7 @@ Videó megosztása… Nem sikerült megosztani a videót, próbálja meg újra később… Videó letöltése… - Hashtag keresése: #%1$s + Kulcsszó keresése: #%1$s Innentől NE fordítsa le Az itt látható nyelvek nem lesznek lefordítva. Az eltávolításához és az újbóli fordításhoz válasszon ki egy nyelvet. Fordítás erre: @@ -1454,7 +1460,7 @@ Blossom-hitelesítés Közvetítési átjátszók Könyvjelzőlista - Napi bejegyzés + Napi időpont Naptár Találkozó Időpont-visszaigazolás @@ -1502,7 +1508,7 @@ Git tároló Git válasz Zap-célok - Hashtag-követések + Kulcsszó-követések Kiemelések Http-hitelesítés Indexelő átjátszók listája @@ -1567,13 +1573,14 @@ Rövidek Hangüzenet Hangos válasz + Webes könyvjelző Wiki Kezdje egy remek hírfolyammal, követve azokat az embereket, akikben megbízik. Követési lista importálása Felhasználók kiválasztása a követéshez Importálandó profil keresés, npub1…, aliz@pelda.hu - Támogatja az npub, nprofile, NIP-05, hex és namecoin (.bit, d/, id/) formátumokat + Támogatja az npub, nprofile, NIP-05, hex és Namecoin (.bit) formátumokat Követési lista keresése Borravaló %1$d felhasználói fiók megtalálva @@ -1595,6 +1602,21 @@ Összes kijelölése Üzemidő: %1$d%% Namecoin-beállítások + Kapcsolat tesztelése + Kiszolgálók tesztelése… + Kapcsolódva + Sikertelen + Teszteredmények + Diagnosztika + Utoljára tesztelve + Eszközinformáció + TLS-információ + Még nem volt futtatva a tesztelés + %d ms + Megbízik a kiszolgáló tanúsítványában? + A(z) %1$s kiszolgáló olyan tanúsítványt mutatott be, amely még nem szerepel az Ön megbízható tanúsítványai között. Ellenőrizze, hogy az alábbi ujjlenyomat megegyezik-e a szerver üzemeltetője által közzétett adattal, majd döntse el, megbízik-e benne a jövőbeni kapcsolódások során. + Elfogadás + Elutasítás Átjátszószinkronizálás Átjátszószinkronizálás Tegye közzé újra az eseményeit az összes ismert átjátszón, hogy a kimenő, beérkező és privát üzenetek átjátszói mindig naprakészek legyenek. Wi-Fi-kapcsolat szükséges – ez jelentős adatforgalmat eredményezhet. @@ -1682,4 +1704,20 @@ Összes Utoljára szinkronizálva: %1$s Utolsó szinkronizálás óta + Webes könyvjelzők + Még nincsenek webes könyvjelzők. Koppintson a „+” gombra a hozzáadáshoz. + Webes könyvjelző hozzáadása + Webes könyvjelző szerkesztése + Webcím + https://pelda.hu + Cím + Könyvjelző neve + Leírás + Egy rövid leírás + Címkék (vesszővel elválasztva) + nostr, tech, blog + Mentés + Törlés + Törli ezt a webes könyvjelzőt? + Webcím megnyitása diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 95c094a36f..ecc62663d3 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -524,8 +524,8 @@ Tak Nie Lista obserwowanych - Obserwowane - Wszystkie obserwowane + Wszystkie obserwacje + Obserwowane osoby Domyślna lista obserwowanych Obserwowani przez proxy W pobliżu @@ -1504,7 +1504,7 @@ Repozytorium Git Odpowiedź Git Cele Zap-a - Obserwacja hashtagów + Obserwowane hashtagi Najważniejsze informacje Autoryzacja http Indeks listy transmiterów @@ -1569,6 +1569,7 @@ Filmiki Wiadomość głosowa Odpowiedź głosowa + Zakładka Wiki Stwórz świetny kanał, obserwując te same osoby, które obserwuje zaufana osoba. Importuj listę obserwowanych @@ -1597,6 +1598,20 @@ Wybierz Wszystkie Czas działania %1$d%% Ustawienia Namecoin + Test połączenia + Testowanie serwerów… + Podłączony + Wyniki testu + Diagnostyka + Ostatni test + Informacje o urządzeniu + Informacje o TLS + Nie wykonano jeszcze testu + %dms + Zaufać certyfikatowi serwera? + Serwer %1$s przedstawił certyfikat, który nie znajduje się jeszcze w Twoim magazynie certyfikatów zaufanych. Sprawdź, czy poniższy Fingerprint odpowiada wartości opublikowanej przez operatora serwera, a następnie zdecyduj, czy chcesz zaufać temu certyfikatowi w przypadku przyszłych połączeń. + Zaufaj + Odrzuć Synchronizacja Transmitera Synchronizacja Transmitera Opublikuj ponownie swoje wpisy na wszystkich znanych transmiterach, aby zaktualizować transmitery odbiorcze, nadawcze i wiadomości prywatnych. Wymagane połączenie Wi-Fi — może to spowodować zużycie dużej ilości danych. @@ -1684,4 +1699,20 @@ Cały czas Ostatnia synchronizacja %1$s Od ostatniej synchronizacji + Zakładki + Brak zakładek. Naciśnij + aby dodać nową zakładkę. + Dodaj zakładkę + Edytuj zakładkę + Adres URL + https://domena.pl + Tytuł + Tytuł zakładki + Opis + Krótki opis + Tagi (oddzielone przecinkami) + nostr, technika, blog + Zapisz + Usuń + Usunąć tę zakładkę? + Otwórz adres URL diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index e22277af24..17b84e58b4 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -61,6 +61,8 @@ O assinador não autorizou a descriptografia necessária para realizar esta operação. Ative as descriptografias NIP-44 no seu aplicativo de assinatura e tente novamente Assinador não encontrado O aplicativo de assinatura foi desinstalado? Verifique se ele está instalado e com esta conta. Saia e entre novamente se ele foi alterado. + Assinador se comportou de forma inesperada + O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador. Zaps Contagem de visualizações Impulsionar @@ -446,6 +448,8 @@ Zap máximo Consenso (0–100)% + Escolha única + Múltipla escolha Data e hora de encerramento da enquete Enquete encerra em %1$s Fechar depois @@ -485,6 +489,9 @@ Destinatário e o público não sabem quem enviou o pagamento Sem Zap Nenhum traço no Nostr, apenas na Lightning + Anônimo + Postar como uma nova identidade descartável. Sua conta não será vinculada a esta resposta. + Esta resposta será postada a partir de uma nova identidade anônima Servidor de arquivos Escolha um servidor para onde enviar este arquivo LnAddress ou @Usuário @@ -1324,6 +1331,7 @@ Hashtags Comunidades Listas + Relés Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1560,6 +1568,7 @@ Shorts Mensagem de voz Resposta de voz + Marcador Web Wiki Comece com um ótimo feed seguindo as mesmas pessoas que alguém em quem você confia. Importar lista de seguidos @@ -1675,4 +1684,20 @@ Todo o período Última sincronização: %1$s Desde a última sincronização + Marcadores Web + Nenhum marcador web ainda. Toque em + para adicionar. + Adicionar Marcador Web + Editar Marcador Web + URL + https://exemplo.com + Título + Título do marcador + Descrição + Uma breve descrição + Tags (separadas por vírgula) + nostr, tech, blog + Salvar + Excluir + Excluir este marcador web? + Abrir URL diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index da5af67392..758e15e197 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -72,6 +72,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Podpisnik ni odobril dešifriranja, ki je potrebno za to operacijo. V aplikaciji za podpisovanje aktivirajte NIP-44 možnost dešifriranja in poskusite znova Ne najdem podpisnika Ali je bila aplikacija za podpisovanje odstranjena? Preverite, ali je aplikacija za podpisovanje nameščena in ima dostop do tega računa. Odjavite se in ponovno prijavite, če se je aplikacija morda spremenila. + Napačno delovanje podpisnika + Zunanji podpisnik je vrnil neobičajen odgovor. Morda gre za napako v aplikaciji Amethyst ali v podpisniku. Zapi Števec vpogledov Pošlji naprej @@ -118,7 +120,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nov kanal Ime kanala Moja vrhunska skupina - Url slike + URL slike URL slike (Neobvezno) Opis Ne najdem opisa @@ -193,7 +195,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Anonimiziraj Prilagodi višino tona svojega glasu: Opomba: osnovne spremembe višine tona lahko poslušalci potencialno razveljavijo. Uporabnik nima nastavljenega \"lightning\" naslova za sprejem satoshi-jev - "odgovori tukaj.. " + "odgovori tukaj… " Kopira ID zapiska v odložišče za deljenje v Nostr Kopiraj ID kanala (zapisek) v odložišče Uredi metapodatke kanala @@ -301,6 +303,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nostr naslov t. i. Nip-05 nikoli zdaj + sekunde h m d @@ -387,7 +390,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ročno razdeli zape Zaznamki Privzeti zaznamki - Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev. + Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev Osnutki Privatni zaznamki Javni zaznamki @@ -441,7 +444,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Napredno: ročni vnos podatkov o povezavi Zneski za hitre zape Prikaže se ob pritisku na gumb za zappe. Tapnite znesek, da ga odstranite. Če pustite prazno, se bo ob vsakem zappu odprlo okno za vnos poljubnega zneska. - Zap zasebnost + Zasebnost zapov Določa, kako je prikazana vaša identiteta, ko pošljete zap. Poveži denarnico Pogled v vsebino releja @@ -458,6 +461,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zap maksimum Soglasje (0–100)% + Ena izbira + Več izbire Datum in čas konca glasovanja Anketa se zaključi %1$s Zapri po @@ -497,6 +502,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prejemnik in javnost ne vejo kdo je poslal plačilo Ni-Zap Brez sledi v Nostr, samo v Lightning + Anonimno + Objavite z novo začasno identiteto. Vaš račun ne bo povezan s tem odgovorom. + Ta odgovor bo objavljen z novo anonimno identiteto Datotečni strežnik Izberi strežnik za nalaganje te datoteke LnNaslov ali @Uporabnik @@ -753,7 +761,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nadzor dostopa Minimalen PoW Auth - Potrebna avtentikacija + Potrebna je avtentikacija Plačilo Potrebno je plačilo Največja dolžina sporočila @@ -826,7 +834,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Slog galerije profila Izberi slog galerije Naloži sliko - Pošiljatelji nezaželjenih vsebin + Pošiljatelji nezaželenih vsebin Utišano. Klikni za vklop zvoka Zvok je prižgan. Klikni da ga utišaš Skoči nazaj za %d sekund @@ -1260,6 +1268,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. + Uredi članek Uredi objavo Prošnja za izboljšavo objave Povzetek sprememb @@ -1339,6 +1348,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ključniki Skupnosti Seznami + Releji Odjava ob zaklepu naprave Zasebno sporočilo Javno sporočilo @@ -1575,6 +1585,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kratki videoposnetki Zvočno sporočilo Zvočni odgovori + Spletni zaznamek Wiki Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate. Uvozi seznam sledenih @@ -1690,4 +1701,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Vse do zdaj Zadnja sinhronizacija: %1$s Od zadnje sinhronizacije + Spletni zaznamki + Seznam spletnih zaznamkov je prazen. Dodajte ga s pritiskom na +. + Dodaj spletni zaznamek + Uredi spletni zaznamek + URL + https://primer.si + Naslov + Naslov zaznamka + Opis + Kratek opis + Oznake (Ločite z vejicami) + nostr, tehnika , blog + Shrani + Izbriši + Izbriši ta spletni zaznamek + Odpri URL diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 777f8fa18f..3dc85edaee 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -61,6 +61,8 @@ Signatären har inte godkänt den dekryptering som krävs. Aktivera NIP-44-dekryptering i din signeringsapp och försök igen Signatör saknas Har signeringsappen avinstallerats? Kontrollera om den är installerad och innehåller det här kontot. Logga ut och in igen om den har ändrats. + Signatären betedde sig oväntat + Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären. Zaps Antal visningar Boosta @@ -446,6 +448,8 @@ Maximal Zap Konsensus (0–100)% + Enkelt val + Flerval Stängningsdatum och tid Omröstningen stänger om %1$s Avsluta efter @@ -485,6 +489,9 @@ Mottagaren och allmänheten vet inte vem som skickade betalningen Ingen Zap Inga spår i Nostr, bara i Lightning + Anonym + Publicera som en ny engångsidentitet. Ditt konto kommer inte att kopplas till detta svar. + Detta svar kommer att publiceras från en ny anonym identitet Fil Server Välj en server att ladda upp denna fil till LnAdress eller @Användare @@ -1323,6 +1330,7 @@ Hashtaggar Gemenskaper Listor + Reläer Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1559,6 +1567,7 @@ Shorts Röstmeddelande Röstsvar + Webbbokmärke Wiki Kom igång med ett bra flöde genom att följa samma personer som någon du litar på. Importera följarlista @@ -1674,4 +1683,20 @@ All tid Senaste synkronisering: %1$s Sedan senaste synkronisering + Webbbokmärken + Inga webbbokmärken ännu. Tryck på + för att lägga till. + Lägg till webbbokmärke + Redigera webbbokmärke + URL + https://exempel.se + Titel + Bokmärkets titel + Beskrivning + En kort beskrivning + Taggar (kommaseparerade) + nostr, tech, blog + Spara + Radera + Radera detta webbbokmärke? + Öppna URL diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 38d929d100..0066b1d1fb 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -28,8 +28,8 @@ 中继器图标 未知作者 复制文本 - 复制作者@npub - 复制笔记ID + 复制作者公钥 ID + 复制笔记 ID 广播 获取公开时间戳 OpenTimestamps:待确认 @@ -61,6 +61,8 @@ 签名器没有授权解密操作,请在签名器中授予 NIP-44 解密权限并重试。 未找到签名器 签名器被卸载?请检查是否已经安装了签名器以及其中是否存在该账户。变更签名器需要注销后重新登录。 + 签名器行为异常 + 外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。 打闪 浏览次数 提升 @@ -70,7 +72,7 @@ 原版 引用 复刻 - 提议编辑 + 提出修改建议 新的聪金额 添加 "回复 " @@ -114,7 +116,7 @@ "关于我们.. " 你在想什么? 写一条消息… - 发布 + 贴文 保存 创建 重命名 @@ -183,7 +185,7 @@ 更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。 用户尚未设置闪电地址以接收聪 "🔏在此回复… " - 复制笔记ID到剪贴板,供分享 + 复制笔记ID到剪贴板以便于在 Nostr 中分享 复制频道ID(笔记)到剪贴板 修改频道元数据 加入 @@ -196,7 +198,7 @@ Mod 队列 笔记 回复 - 你的 + 互动 相册 "关注" "举报" @@ -326,7 +328,7 @@ 你收到了新的徽章奖励 徽章奖励授予 文本已复制到剪贴板 - 复制作者的 @npub 到剪贴板 + 已复制作者公钥 ID 到剪贴板 已复制笔记ID (@note1) 到剪贴板 选择文本 "<无法解密私密消息>\n\n;你被 %1$s 和 %2$s 之间的私人/加密会话引用。" @@ -597,7 +599,7 @@ Orbot Socks 端口 启动 Tor - 使用内置的版本或者 Orbot + 使用内置 Tor 或者 Orbot Tor 和隐私预设 快速修改下方的设置 Onion 链接或中继地址 @@ -1131,7 +1133,7 @@ 调整顺序 回复 回复此笔记 - Boost + 提升 转发或引用此笔记 点赞 使用表情符号回应笔记 @@ -1334,6 +1336,7 @@ 话题标签 社区 列表 + 中继 当设备锁定时注销 私信 公开消息 @@ -1570,6 +1573,7 @@ 短篇 语音消息 语音回复 + 网络书签 维基 关注你信任的人所关注的人来开启优质的源。 导入关注列表 @@ -1598,6 +1602,21 @@ 全选 %1$d%% 运行时间 Namecoin 设置 + 测试连接 + 正在测试服务器… + 已连接 + 已失败 + 测试结果 + 诊断 + 上次测试 + 设备信息 + TLS 信息 + 尚未运行测试 + %d毫秒 + 信任服务器证书? + 服务器 %1$s 提供的证书尚未在您的信任存储中。 验证下面的指纹与服务器运营者发布的内容匹配,然后选择是否信任它来进行未来连接。 + 信任 + 拒绝 中继同步 中继同步 在所有已知的中继重新发布您的事件,以保持您的发件箱、收件箱和私信中继是最新的。 需要 Wi-Fi - 这可能使用大量数据。 @@ -1639,7 +1658,7 @@ 私信 个人资料 中继设置 - 上次看见在 %1$s 秒前 + %1$s 前活跃 <%1$s 连接中 下载中 @@ -1685,4 +1704,20 @@ 全部时间 上次同步: %1$s 自上次同步后 + 网络书签 + 暂无网络书签。点击 + 添加一个。 + 添加网络书签 + 编辑网络书签 + URL + https://example.com + 标题 + 书签标题 + 描述 + 一段简短描述 + 标签(以逗号分隔) + nostr, tech, blog + 保存 + 删除 + 删除该网络书签? + 打开 URL diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3ac992398d..e5a006c131 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -418,6 +418,10 @@ Remove from Private Bookmarks Remove from Public Bookmarks + Pinned Notes + Pin to Profile + Unpin from Profile + Bookmark Lists Icon for bookmark list New Bookmark List @@ -1827,7 +1831,7 @@ Select Users to Follow Profile to import from search, npub1…, alice@example.com - Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/) + Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit) Look Up Follow List Tip %1$d accounts found @@ -1849,6 +1853,21 @@ Select All %1$d%% uptime Namecoin Settings + Test Connection + Testing servers… + Connected + Failed + Test Results + Diagnostics + Last test + Device Info + TLS Info + No test run yet + %dms + Trust Server Certificate? + The server %1$s presented a certificate not yet in your trust store. Verify the fingerprint below matches what the server operator published, then choose whether to trust it for future connections. + Trust + Reject Relay Sync Relay Sync Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt index 4eb495af60..661caca6e3 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt @@ -56,7 +56,7 @@ class NamecoinSettingsTest { assertEquals("abc123def.onion", s!!.host) assertEquals(50001, s.port) assertFalse(s.useSsl) - assertTrue(s.trustAllCerts) + assertTrue(s.usePinnedTrustStore) } @Test @@ -131,7 +131,7 @@ class NamecoinSettingsTest { assertTrue(servers[0].useSsl) assertEquals("server2.onion", servers[1].host) assertFalse(servers[1].useSsl) - assertTrue(servers[1].trustAllCerts) + assertTrue(servers[1].usePinnedTrustStore) } @Test diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt new file mode 100644 index 0000000000..b501e83993 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Rfc3986UrlNormalizerBenchmark.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.utils.Rfc3986 +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Benchmark, which will execute on an Android device. + * + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will output the + * result. Modify your code to see how it affects performance. + */ +@RunWith(AndroidJUnit4::class) +class Rfc3986UrlNormalizerBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + @Test + fun normalize() { + benchmarkRule.measureRepeated { + Rfc3986.normalize("wss://relay.damus.io") + } + } + + @Test + fun parseUrlDetector() { + benchmarkRule.measureRepeated { + UrlDetector("wss://nostr.mom/").detect() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt index 0bef42b0ba..245e7f7a5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt @@ -49,11 +49,11 @@ sealed interface Nip05State { fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted) - suspend fun checkAndUpdate(nip05Client: INip05Client) { + suspend fun checkAndUpdate(nip05ClientBuilder: () -> INip05Client) { if (verificationState.value.isExpired()) { markAsVerifying() try { - if (nip05Client.verify(nip05, hexKey)) { + if (nip05ClientBuilder().verify(nip05, hexKey)) { markAsVerified() } else { markAsInvalid() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index b7787ff5c5..926ed9c994 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers +import androidx.compose.runtime.Stable import java.util.concurrent.ConcurrentHashMap import kotlin.collections.forEach @@ -28,6 +29,7 @@ import kotlin.collections.forEach * to relays. There may be multiple duplications in these * subscriptions since we do not control when screens are removed. */ +@Stable abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControls, Subscribable { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt index eff5f89099..da8e82276a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers +import androidx.compose.runtime.Stable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -36,6 +37,7 @@ import java.util.concurrent.ConcurrentHashMap * also allows the subscription itself to change over time as a * flow, which trigger an update on the relay subscriptions */ +@Stable abstract class MutableComposeSubscriptionManager( val scope: CoroutineScope, ) : ComposeSubscriptionManagerControls { diff --git a/docs/design/banner_by_purple_painter.jpeg b/docs/design/banner_by_purple_painter.jpeg new file mode 100644 index 0000000000..4117c124cb Binary files /dev/null and b/docs/design/banner_by_purple_painter.jpeg differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg b/docs/design/profile_banner_old.jpg similarity index 100% rename from amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg rename to docs/design/profile_banner_old.jpg diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b82ca191e6..b6b84f89be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ materialIconsExtended = "1.7.3" media3 = "1.9.3" mockk = "1.14.9" kotlinx-coroutines-test = "1.10.2" +negentropyKmp = "1.0.1" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.7" okhttp = "5.3.2" @@ -54,7 +55,6 @@ torAndroid = "0.4.9.5.1" translate = "17.0.3" jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" -uriReferenceKmp = "1.0" vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" @@ -124,7 +124,6 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } -uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" } vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } @@ -156,6 +155,7 @@ markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", n mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} +negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", version.ref = "negentropyKmp" } net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 5b49c924ff..b10c683a81 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -118,9 +118,9 @@ kotlin { // SQLite KMP driver for event store api(libs.androidx.sqlite) implementation(libs.androidx.sqlite.bundled) - - // RFC3986 library(normalizes URLs) - api(libs.uri.reference.kmp) + + // Negentropy set reconciliation (NIP-77) + api(libs.negentropy.kmp) } } @@ -170,7 +170,6 @@ kotlin { dependencies { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - } } @@ -192,7 +191,6 @@ kotlin { // Bitcoin secp256k1 bindings to Android api(libs.secp256k1.kmp.jni.android) - } } @@ -204,7 +202,6 @@ kotlin { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - // SQLite bundled driver for Host tests implementation(libs.androidx.sqlite.bundled.jvm) } @@ -222,7 +219,6 @@ kotlin { // Bitcoin secp256k1 bindings to Android api(libs.secp256k1.kmp.jni.android) - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt index 4d3eba2998..34d2457523 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -27,6 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -78,6 +81,21 @@ object CommandKSerializer : KSerializer { add(FilterKSerializer.serializeToElement(filter)) } } + + is NegOpenCmd -> { + add(JsonPrimitive(value.subId)) + add(FilterKSerializer.serializeToElement(value.filter)) + add(JsonPrimitive(value.initialMessage)) + } + + is NegMsgCmd -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.message)) + } + + is NegCloseCmd -> { + add(JsonPrimitive(value.subId)) + } } } jsonEncoder.encodeJsonElement(element) @@ -119,6 +137,27 @@ object CommandKSerializer : KSerializer { AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) } + NegOpenCmd.LABEL -> { + NegOpenCmd( + subId = array[1].jsonPrimitive.content, + filter = FilterKSerializer.deserializeFromElement(array[2].jsonObject), + initialMessage = array[3].jsonPrimitive.content, + ) + } + + NegMsgCmd.LABEL -> { + NegMsgCmd( + subId = array[1].jsonPrimitive.content, + message = array[2].jsonPrimitive.content, + ) + } + + NegCloseCmd.LABEL -> { + NegCloseCmd( + subId = array[1].jsonPrimitive.content, + ) + } + else -> { throw IllegalArgumentException("Message $type is not supported") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 34ee1b491a..4edf4ee4f0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -94,6 +96,16 @@ object MessageKSerializer : KSerializer { is EoseMessage -> { add(JsonPrimitive(value.subId)) } + + is NegMsgMessage -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.message)) + } + + is NegErrMessage -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.reason)) + } } } jsonEncoder.encodeJsonElement(element) @@ -148,6 +160,20 @@ object MessageKSerializer : KSerializer { CountMessage(queryId, result) } + NegMsgMessage.LABEL -> { + NegMsgMessage( + subId = array[1].jsonPrimitive.content, + message = array[2].jsonPrimitive.content, + ) + } + + NegErrMessage.LABEL -> { + NegErrMessage( + subId = array[1].jsonPrimitive.content, + reason = if (array.size > 2) array[2].jsonPrimitive.content else "", + ) + } + else -> { throw IllegalArgumentException("Message $type is not supported") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index 0ac30f7969..c7520138d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.CancellationException @Stable class Nip05Client( val fetcher: Nip05Fetcher, - val namecoinResolver: NamecoinNameResolver? = null, + val namecoinResolverBuilder: (() -> NamecoinNameResolver)? = null, ) : INip05Client { val parser = Nip05Parser() @@ -37,8 +37,8 @@ class Nip05Client( hexKey: HexKey, ): Boolean { // Namecoin: route .bit domains to blockchain verification - if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { - val result = namecoinResolver.resolve(nip05.toValue()) + if (namecoinResolverBuilder != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolverBuilder().resolve(nip05.toValue()) return result?.pubkey == hexKey } @@ -61,8 +61,8 @@ class Nip05Client( override suspend fun get(nip05: Nip05Id): Nip05KeyInfo? { // Namecoin: route .bit domains to blockchain resolution - if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { - val result = namecoinResolver.resolve(nip05.toValue()) ?: return null + if (namecoinResolverBuilder != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolverBuilder().resolve(nip05.toValue()) ?: return null return Nip05KeyInfo(result.pubkey, result.relays) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index f31edfe7d1..4c6c969379 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -44,8 +44,14 @@ data class ElectrumxServer( val host: String, val port: Int, val useSsl: Boolean = true, - /** If true, accept any certificate (self-signed, expired, etc.) */ - val trustAllCerts: Boolean = false, + /** + * If true, use the pinned trust store (hardcoded + TOFU-pinned certs + * plus system CAs) instead of the default system-only trust store. + * + * Required for ElectrumX servers that use self-signed certificates, + * which is the norm for the Namecoin ElectrumX ecosystem. + */ + val usePinnedTrustStore: Boolean = false, ) /** @@ -72,12 +78,27 @@ sealed class NamecoinLookupException( ) : NamecoinLookupException("All ElectrumX servers unreachable", lastError) } +/** + * Result of testing connectivity to a single ElectrumX server. + */ +data class ServerTestResult( + val server: ElectrumxServer, + val success: Boolean, + val responseTimeMs: Long, + val error: String? = null, + val tlsVersion: String? = null, + /** PEM-encoded server certificate, captured during test for TOFU pinning. */ + val serverCertPem: String? = null, + /** SHA-256 fingerprint of the server certificate. */ + val certFingerprint: String? = null, +) + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_ELECTRUMX_SERVERS = listOf( - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), - ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("46.229.238.187", 57002, useSsl = true, usePinnedTrustStore = true), ) /** Tor-preferred server list: onion primary, clearnet fallback. */ @@ -87,8 +108,8 @@ val TOR_ELECTRUMX_SERVERS = "i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion", 50002, useSsl = true, - trustAllCerts = true, + usePinnedTrustStore = true, ), - ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt index 03194b47cc..13ac854473 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PinListEvent.kt @@ -21,10 +21,15 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,23 +40,70 @@ class PinListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] } +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) + + fun countPins() = tags.count(EventBookmark::isTagged) + + fun pinnedEvents(): List = tags.mapNotNull(EventBookmark::parse) + + fun isPinned(eventId: HexKey): Boolean = tags.any { EventBookmark.isTagged(it, eventId) } companion object { - const val KIND = 33888 - const val ALT = "Pinned Posts" + const val KIND = 10001 + const val ALT = "Pinned Notes" + + fun createPinAddress(pubKey: HexKey) = Address(KIND, pubKey, "") suspend fun create( - pins: List, + pin: EventBookmark, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): PinListEvent { - val tags = mutableListOf>() - pins.forEach { tags.add(arrayOf("pin", it)) } - tags.add(AltTag.assemble(ALT)) + val tags = arrayOf(pin.toTagArray(), AltTag.assemble(ALT)) + return signer.sign(createdAt, KIND, tags, "") + } - return signer.sign(createdAt, KIND, tags.toTypedArray(), "") + suspend fun add( + earlierVersion: PinListEvent, + pin: EventBookmark, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PinListEvent = + resign( + tags = earlierVersion.tags.plus(pin.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + + suspend fun remove( + earlierVersion: PinListEvent, + pin: EventBookmark, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PinListEvent = + resign( + tags = earlierVersion.tags.remove(pin.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PinListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, "") } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegCloseCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegCloseCmd.kt new file mode 100644 index 0000000000..1fe05efe97 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegCloseCmd.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command + +class NegCloseCmd( + val subId: String, +) : Command { + override fun label(): String = LABEL + + override fun isValid() = subId.isNotEmpty() + + companion object { + const val LABEL = "NEG-CLOSE" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt new file mode 100644 index 0000000000..a81e1ded1f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message + +class NegErrMessage( + val subId: String, + val reason: String, +) : Message { + override fun label() = LABEL + + companion object { + const val LABEL = "NEG-ERR" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgCmd.kt new file mode 100644 index 0000000000..d8eedd15d2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgCmd.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command + +class NegMsgCmd( + val subId: String, + val message: String, +) : Command { + override fun label(): String = LABEL + + override fun isValid() = subId.isNotEmpty() && message.isNotEmpty() + + companion object { + const val LABEL = "NEG-MSG" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgMessage.kt new file mode 100644 index 0000000000..dd97b52475 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegMsgMessage.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message + +class NegMsgMessage( + val subId: String, + val message: String, +) : Message { + override fun label() = LABEL + + companion object { + const val LABEL = "NEG-MSG" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegOpenCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegOpenCmd.kt new file mode 100644 index 0000000000..7093e17ca3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegOpenCmd.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +class NegOpenCmd( + val subId: String, + val filter: Filter, + val initialMessage: String, +) : Command { + override fun label(): String = LABEL + + override fun isValid() = subId.isNotEmpty() && initialMessage.isNotEmpty() + + companion object { + const val LABEL = "NEG-OPEN" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt new file mode 100644 index 0000000000..7401a63be8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Callback interface for negentropy reconciliation results. + */ +interface INegentropyListener { + fun onHaveIds( + relay: NormalizedRelayUrl, + subId: String, + haveIds: List, + ) + + fun onNeedIds( + relay: NormalizedRelayUrl, + subId: String, + needIds: List, + ) + + fun onComplete( + relay: NormalizedRelayUrl, + subId: String, + ) + + fun onError( + relay: NormalizedRelayUrl, + subId: String, + reason: String, + ) +} + +/** + * Manages NIP-77 negentropy sync sessions across multiple relays. + * + * This class acts as a [IRelayClientListener] that intercepts NEG-MSG and NEG-ERR + * messages and drives the reconciliation protocol. It maintains active sessions + * per relay and subscription ID. + * + * Usage: + * 1. Register this as a listener on the NostrClient + * 2. Call [startSync] with a filter and local events to begin reconciliation + * 3. Receive results via [INegentropyListener] callbacks + */ +class NegentropyManager( + private val listener: INegentropyListener, +) : IRelayClientListener { + private val activeSessions = mutableMapOf>() + + fun startSync( + relay: IRelayClient, + subId: String, + filter: Filter, + localEvents: List, + frameSizeLimit: Long = 0, + ) { + val session = NegentropySession(subId, filter, localEvents, frameSizeLimit) + activeSessions[subId] = Pair(relay.url, session) + + val openCmd = session.open() + relay.sendOrConnectAndSync(openCmd) + } + + fun closeSync( + relay: IRelayClient, + subId: String, + ) { + val (_, session) = activeSessions.remove(subId) ?: return + relay.sendIfConnected(session.close()) + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is NegMsgMessage -> handleNegMsg(relay, msg) + is NegErrMessage -> handleNegErr(relay, msg) + } + } + + private fun handleNegMsg( + relay: IRelayClient, + msg: NegMsgMessage, + ) { + val (relayUrl, session) = activeSessions[msg.subId] ?: return + + val result = session.processMessage(msg.message) + + if (result.haveIds.isNotEmpty()) { + listener.onHaveIds(relayUrl, msg.subId, result.haveIds) + } + if (result.needIds.isNotEmpty()) { + listener.onNeedIds(relayUrl, msg.subId, result.needIds) + } + + if (result.nextCmd != null) { + relay.sendIfConnected(result.nextCmd) + } else { + activeSessions.remove(msg.subId) + relay.sendIfConnected(session.close()) + listener.onComplete(relayUrl, msg.subId) + } + } + + private fun handleNegErr( + relay: IRelayClient, + msg: NegErrMessage, + ) { + val (relayUrl, _) = activeSessions.remove(msg.subId) ?: return + listener.onError(relayUrl, msg.subId, msg.reason) + } + + override fun onDisconnected(relay: IRelayClient) { + val toRemove = activeSessions.filter { it.value.first == relay.url } + toRemove.forEach { (subId, pair) -> + activeSessions.remove(subId) + listener.onError(pair.first, subId, "closed: relay disconnected") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt new file mode 100644 index 0000000000..4fe221d002 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyServerSession.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.negentropy.Negentropy +import com.vitorpamplona.negentropy.storage.StorageVector +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.Hex + +/** + * Server-side handler for NIP-77 negentropy reconciliation. + * + * Used when acting as a relay (or relay-relay sync) to respond to + * incoming NEG-OPEN and NEG-MSG from a client. + * + * Usage: + * 1. On NEG-OPEN: create a [NegentropyServerSession] with the matching local events + * 2. Call [processMessage] with the initial hex message from NEG-OPEN + * 3. Send back the resulting [NegMsgMessage] + * 4. On subsequent NEG-MSG: call [processMessage] again and send the response + */ +class NegentropyServerSession( + val subId: String, + localEvents: List, + frameSizeLimit: Long = 0, +) { + private val storage = StorageVector() + private val negentropy: Negentropy + + init { + for (event in localEvents) { + storage.insert(event.createdAt, event.id) + } + storage.seal() + negentropy = Negentropy(storage, frameSizeLimit) + } + + fun processMessage(hexMessage: String): NegMsgMessage? { + val msgBytes = Hex.decode(hexMessage) + val result = negentropy.reconcile(msgBytes) + return if (result.msg != null) { + NegMsgMessage( + subId = subId, + message = Hex.encode(result.msg!!), + ) + } else { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySync.kt new file mode 100644 index 0000000000..1cde06cdf3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySync.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.negentropy.Negentropy +import com.vitorpamplona.negentropy.storage.StorageVector +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.Hex + +/** + * Manages a single NIP-77 negentropy reconciliation session with a relay. + * + * Usage: + * 1. Create a NegentropySession with local events and a filter + * 2. Call [open] to get the NEG-OPEN command + * 3. Feed relay NEG-MSG responses via [processMessage] + * 4. If [processMessage] returns a non-null [NegMsgCmd], send it back + * 5. Repeat until [processMessage] returns a result with a null command (reconciliation complete) + * 6. Use [haveIds] and [needIds] from the result to send/request events + * 7. Call [close] to produce a NEG-CLOSE command when done + */ +class NegentropySession( + val subId: String, + val filter: Filter, + localEvents: List, + frameSizeLimit: Long = 0, +) { + private val storage = StorageVector() + private val negentropy: Negentropy + + init { + for (event in localEvents) { + storage.insert(event.createdAt, event.id) + } + storage.seal() + negentropy = Negentropy(storage, frameSizeLimit) + } + + fun open(): NegOpenCmd { + val initialMessage = negentropy.initiate() + return NegOpenCmd( + subId = subId, + filter = filter, + initialMessage = Hex.encode(initialMessage), + ) + } + + fun processMessage(hexMessage: String): ReconcileResult { + val msgBytes = Hex.decode(hexMessage) + val result = negentropy.reconcile(msgBytes) + + val haveIds = result.sendIds.map { it.toHexString() } + val needIds = result.needIds.map { it.toHexString() } + + val nextCmd = + if (result.msg != null) { + NegMsgCmd( + subId = subId, + message = Hex.encode(result.msg!!), + ) + } else { + null + } + + return ReconcileResult( + nextCmd = nextCmd, + haveIds = haveIds, + needIds = needIds, + ) + } + + fun close(): NegCloseCmd = NegCloseCmd(subId) +} + +class ReconcileResult( + val nextCmd: NegMsgCmd?, + val haveIds: List, + val needIds: List, +) { + fun isComplete() = nextCmd == null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt index a0bc76d7f1..237674bb2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt @@ -20,47 +20,16 @@ */ package com.vitorpamplona.quartz.utils -import io.kotlingeekdev.urireference.URIReference +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector object Rfc3986 { - fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() + fun parse(url: String) = UrlDetector(url).detect()[0] - fun isValidUrl(url: String): Boolean = - runCatching { - URIReference.parse(url) - }.isSuccess + fun normalize(uri: String): String = parse(uri).fullUrl - fun normalizeAndRemoveFragment(url: String): String = - URIReference - .parse(url) - .normalize()!! - .toStringNoFragment() - .internIfPossible() + fun isValidUrl(url: String): Boolean = runCatching { parse(url) }.isSuccess - fun host(url: String): String = - URIReference - .parse(url) - .host - ?.value - .toString() -} - -fun URIReference.toStringSchemeHost(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - - return sb.toString() -} - -fun URIReference.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (host != null) sb.append("//").append(host.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() + fun normalizeAndRemoveFragment(url: String): String = parse(url).fullUrlWithoutFragment.internIfPossible() + + fun host(url: String): String = parse(url).host } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt index 46927f771d..8782c16e97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt @@ -98,9 +98,9 @@ class Url( if (index != -1) { _scheme = _scheme!!.substring(0, index) } + _scheme = _scheme!!.lowercase() } else if (!originalUrl.startsWith("//")) { - _scheme = - DEFAULT_SCHEME + _scheme = DEFAULT_SCHEME } } return _scheme ?: "" @@ -125,7 +125,10 @@ class Url( val host: String get() { if (this.rawHost == null) { - this.rawHost = getPart(UrlPart.HOST) + this.rawHost = + getPart(UrlPart.HOST)?.let { + lowercaseLiteralChars(normalizeComponent(it)) + } if (exists(UrlPart.PORT)) { this.rawHost = rawHost?.let { @@ -142,8 +145,7 @@ class Url( val port: Int get() { if (_port == 0) { - val portString = - getPart(UrlPart.PORT) + val portString = getPart(UrlPart.PORT) if (!portString.isNullOrEmpty()) { _port = portString.toIntOrNull() ?: -1 } else { @@ -156,14 +158,9 @@ class Url( val path: String? get() { if (this.rawPath == null) { - this.rawPath = - if (exists(UrlPart.PATH)) { - getPart( - UrlPart.PATH, - ) - } else { - "/" - } + this.rawPath = getPart(UrlPart.PATH)?.let { + normalizeComponent(removeDotSegments(it)) + } ?: "/" } return this.rawPath } @@ -171,7 +168,10 @@ class Url( val query: String get() { if (_query == null) { - _query = getPart(UrlPart.QUERY) + _query = + getPart(UrlPart.QUERY)?.let { + normalizeComponent(it) + } ?: "" } return _query ?: "" } @@ -179,7 +179,9 @@ class Url( val fragment: String get() { if (_fragment == null) { - _fragment = getPart(UrlPart.FRAGMENT) + _fragment = getPart(UrlPart.FRAGMENT)?.let { + normalizeComponent(it) + } ?: "" } return _fragment ?: "" } @@ -190,10 +192,10 @@ class Url( val usernamePasswordParts: List = usernamePassword.substring(0, usernamePassword.length - 1).split(":") if (usernamePasswordParts.size == 1) { - _username = usernamePasswordParts[0] + _username = normalizeComponent(usernamePasswordParts[0]) } else if (usernamePasswordParts.size == 2) { - _username = usernamePasswordParts[0] - _password = usernamePasswordParts[1] + _username = normalizeComponent(usernamePasswordParts[0]) + _password = normalizeComponent(usernamePasswordParts[1]) } } } @@ -242,7 +244,206 @@ class Url( } } + /** + * Removes dot segments from the given path as stated in + * ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4). + * + * @param path + * The path from which dot segments are to be removed. + * + * @return + * The path from which dot segments are removed. + */ + fun removeDotSegments(path: String): String { + // Initialize the input with the no-appended path components and the output + // with the empty string. + var input = path + var output = "" + + // While the input is not empty, loop the following steps. + while (input.isNotEmpty()) { + // If the input begins with a prefix of "../" or "./", then + // remove that prefix from the input; + if (DOT_DOT_SLASH.find(input) != null) { + input = DOT_DOT_SLASH.replaceFirst(input, "") + continue + } + + // If the input begins with a prefix of "/./" or "/.", where + // "." is a complete path segment, then replace that prefix + // with "/" in the input. + if (SLASH_DOT_SLASH.find(input) != null) { + input = SLASH_DOT_SLASH.replaceFirst(input, "/") + continue + } + + // If the input begins with a prefix of "/../" or "/..", + // where ".." is a complete path segment, then replace that + // prefix with "/" in the input and remove the last segment + // and its preceding "/" (if any) from the output. + if (SLASH_DOT_DOT_SLASH.find(input) != null) { + input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/") + output = dropLastSegment(output, true) + continue + } + + // If the input consists only of "." or "..", then remove + // that from the input. + if (DOT_OR_DOT_DOT.find(input) != null) { + input = DOT_OR_DOT_DOT.replaceFirst(input, "") + continue + } + + // Move the first path segment in the input buffer to the + // end of the output, including the initial "/" character + // (if any) and any subsequent characters up to, but not + // including, the next "/" character or the end of the input. + val matchResult = MOVE_REGEX.find(input) + if (matchResult != null) { + input = matchResult.groups["remaining"]!!.value + output += matchResult.groups["firstsegment"]!!.value + continue + } + } + + return output + } + + /** + * Drops the last segment (= characters after the last slash) of a path and + * optionally the last slash. If the path doesn't contain slash, an empty string + * is returned. + * + * @param path + * The path. + * + * @param dropLastSlash + * Whether or not to drop the last slash if present. + * + * @return The path from which the last segment is removed. + */ + fun dropLastSegment( + path: String, + dropLastSlash: Boolean, + ): String { + // The regular expression for the target. + val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX + + // Find the target. (Any inputs matches the pattern.) + m.find(path) + + // Drop the target. + return m.replaceFirst(path, "") + } + + /** + * Unreserved characters per RFC 3986 §2.3: + * ALPHA / DIGIT / "-" / "." / "_" / "~" + */ + private fun isUnreserved(c: Char): Boolean = c.isLetter() || c.isDigit() || c == '-' || c == '.' || c == '_' || c == '~' + + /** + * Normalize percent-encoded triplets in a single URI component string. + * + * For each %XX triplet: + * - If the decoded byte is an unreserved ASCII character → decode it + * - Otherwise → keep encoded but uppercase the hex digits + * + * Non-ASCII bytes (e.g. UTF-8 multi-byte sequences) are left encoded + * since they cannot be unreserved characters. + */ + fun normalizeComponent(input: String): String { + val sb = StringBuilder(input.length) + var i = 0 + + while (i < input.length) { + val c = input[i] + + if (c == '%' && i + 2 < input.length) { + val hex = input.substring(i + 1, i + 3) + + // Validate that both characters are valid hex digits + if (hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) { + val byteValue = hex.toInt(16) + + // Only consider single-byte ASCII values for potential decoding + if (byteValue < 0x80) { + val decoded = byteValue.toChar() + if (isUnreserved(decoded)) { + // Decode: replace %XX with the literal character + sb.append(decoded) + i += 3 + continue + } + } + + // Keep encoded, but uppercase the hex digits + sb.append('%') + sb.append(hex.uppercase()) + i += 3 + continue + } + } + + // Regular character — pass through as-is + sb.append(c) + i++ + } + + return sb.toString() + } + + /** + * Lowercase only the literal (non-percent-encoded) characters in a string. + * Percent-encoded triplets are left untouched so their uppercased hex digits + * are not inadvertently lowercased. + */ + private fun lowercaseLiteralChars(input: String): String { + val sb = StringBuilder(input.length) + var i = 0 + while (i < input.length) { + if (input[i] == '%' && i + 2 < input.length) { + sb.append(input[i]) + sb.append(input[i + 1]) + sb.append(input[i + 2]) + i += 3 + } else { + sb.append(input[i].lowercaseChar()) + i++ + } + } + return sb.toString() + } + companion object { + val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$") + val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$") + + // If the input begins with a prefix of "../" or "./", then + // remove that prefix from the input; + val DOT_DOT_SLASH = Regex("^\\.?\\./") + + // If the input begins with a prefix of "/./" or "/.", where + // "." is a complete path segment, then replace that prefix + // with "/" in the input. + val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)") + + // If the input begins with a prefix of "/../" or "/..", + // where ".." is a complete path segment, then replace that + // prefix with "/" in the input and remove the last segment + // and its preceding "/" (if any) from the output. + val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)") + + // If the input consists only of "." or "..", then remove + // that from the input. + val DOT_OR_DOT_DOT = Regex("^\\.?\\.$") + + // Move the first path segment in the input buffer to the + // end of the output, including the initial "/" character + // (if any) and any subsequent characters up to, but not + // including, the next "/" character or the end of the input. + val MOVE_REGEX = Regex("^(?\\/?[^/]*)(?.*)$") + private const val DEFAULT_SCHEME = "https" private val SCHEME_PORT_MAP: Map = mapOf( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySessionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySessionTest.kt new file mode 100644 index 0000000000..655956239d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySessionTest.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.negentropy.Negentropy +import com.vitorpamplona.negentropy.storage.StorageVector +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NegentropySessionTest { + private fun makeEvent( + id: String, + createdAt: Long, + ): Event = + Event( + id = id, + pubKey = "a".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "", + sig = "b".repeat(64), + ) + + @Test + fun clientServerSync_identicalSets_noIdsExchanged() { + val events = + listOf( + makeEvent("a".repeat(64), 1000), + makeEvent("b".repeat(64), 2000), + makeEvent("c".repeat(64), 3000), + ) + + val clientSession = NegentropySession("sub1", Filter(), events) + val openCmd = clientSession.open() + + assertEquals("NEG-OPEN", openCmd.label()) + assertEquals("sub1", openCmd.subId) + assertTrue(openCmd.initialMessage.isNotEmpty()) + + // Server side: same events + val serverStorage = StorageVector() + for (e in events) { + serverStorage.insert(e.createdAt, e.id) + } + serverStorage.seal() + val serverNeg = Negentropy(serverStorage) + + val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage)) + assertNotNull(serverResponse.msg) + + val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!)) + + // Identical sets: no haves or needs + assertEquals(0, result.haveIds.size) + assertEquals(0, result.needIds.size) + } + + @Test + fun clientServerSync_clientHasExtra_detectsHaveIds() { + val sharedEvents = + listOf( + makeEvent("a".repeat(64), 1000), + makeEvent("b".repeat(64), 2000), + ) + + val clientOnlyEvent = makeEvent("c".repeat(64), 3000) + val clientEvents = sharedEvents + clientOnlyEvent + + val clientSession = NegentropySession("sub1", Filter(), clientEvents) + val openCmd = clientSession.open() + + // Server side: only shared events + val serverStorage = StorageVector() + for (e in sharedEvents) { + serverStorage.insert(e.createdAt, e.id) + } + serverStorage.seal() + val serverNeg = Negentropy(serverStorage) + + val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage)) + assertNotNull(serverResponse.msg) + + val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!)) + + // Client has extra event that server needs + assertTrue(result.haveIds.contains("c".repeat(64))) + assertEquals(0, result.needIds.size) + } + + @Test + fun clientServerSync_serverHasExtra_detectsNeedIds() { + val sharedEvents = + listOf( + makeEvent("a".repeat(64), 1000), + makeEvent("b".repeat(64), 2000), + ) + + val clientSession = NegentropySession("sub1", Filter(), sharedEvents) + val openCmd = clientSession.open() + + // Server side: shared + extra + val serverStorage = StorageVector() + for (e in sharedEvents) { + serverStorage.insert(e.createdAt, e.id) + } + serverStorage.insert(3000, "d".repeat(64)) + serverStorage.seal() + val serverNeg = Negentropy(serverStorage) + + val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage)) + assertNotNull(serverResponse.msg) + + val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!)) + + // Server has an event client needs + assertTrue(result.needIds.contains("d".repeat(64))) + assertEquals(0, result.haveIds.size) + } + + @Test + fun clientServerSync_bothHaveUnique_detectsBothDirections() { + val sharedEvents = + listOf( + makeEvent("a".repeat(64), 1000), + ) + + val clientOnly = makeEvent("b".repeat(64), 2000) + val clientEvents = sharedEvents + clientOnly + + val clientSession = NegentropySession("sub1", Filter(), clientEvents) + val openCmd = clientSession.open() + + // Server side: shared + different extra + val serverStorage = StorageVector() + for (e in sharedEvents) { + serverStorage.insert(e.createdAt, e.id) + } + serverStorage.insert(3000, "c".repeat(64)) + serverStorage.seal() + val serverNeg = Negentropy(serverStorage) + + val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage)) + assertNotNull(serverResponse.msg) + + val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!)) + + assertTrue(result.haveIds.contains("b".repeat(64))) + assertTrue(result.needIds.contains("c".repeat(64))) + } + + @Test + fun clientServerSync_emptyClient_needsAll() { + val clientSession = NegentropySession("sub1", Filter(), emptyList()) + val openCmd = clientSession.open() + + // Server has events + val serverStorage = StorageVector() + serverStorage.insert(1000, "a".repeat(64)) + serverStorage.insert(2000, "b".repeat(64)) + serverStorage.seal() + val serverNeg = Negentropy(serverStorage) + + val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage)) + assertNotNull(serverResponse.msg) + + val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!)) + + assertEquals(0, result.haveIds.size) + assertEquals(2, result.needIds.size) + } + + @Test + fun clientServerSync_emptyServer_hasAll() { + val clientEvents = + listOf( + makeEvent("a".repeat(64), 1000), + makeEvent("b".repeat(64), 2000), + ) + + val clientSession = NegentropySession("sub1", Filter(), clientEvents) + val openCmd = clientSession.open() + + // Server is empty + val serverStorage = StorageVector() + serverStorage.seal() + val serverNeg = Negentropy(serverStorage) + + val serverResponse = serverNeg.reconcile(Hex.decode(openCmd.initialMessage)) + assertNotNull(serverResponse.msg) + + val result = clientSession.processMessage(Hex.encode(serverResponse.msg!!)) + + assertEquals(2, result.haveIds.size) + assertEquals(0, result.needIds.size) + } + + @Test + fun multiRoundSync_withFrameLimit() { + // Create enough events to require multiple rounds with a small frame limit + val clientEvents = + (1..500).map { i -> + makeEvent( + i.toString().padStart(64, '0'), + i.toLong() * 1000, + ) + } + + val serverEvents = + (251..750).map { i -> + Pair(i.toLong() * 1000, i.toString().padStart(64, '0')) + } + + // Small frame limit to force multiple rounds + val clientSession = NegentropySession("sub1", Filter(), clientEvents, frameSizeLimit = 4096) + val openCmd = clientSession.open() + + val serverStorage = StorageVector() + for ((ts, id) in serverEvents) { + serverStorage.insert(ts, id) + } + serverStorage.seal() + val serverNeg = Negentropy(serverStorage, 4096) + + var serverMsg = serverNeg.reconcile(Hex.decode(openCmd.initialMessage)) + assertNotNull(serverMsg.msg) + + val allHaveIds = mutableListOf() + val allNeedIds = mutableListOf() + + var result = clientSession.processMessage(Hex.encode(serverMsg.msg!!)) + allHaveIds.addAll(result.haveIds) + allNeedIds.addAll(result.needIds) + + var rounds = 1 + while (!result.isComplete()) { + rounds++ + serverMsg = serverNeg.reconcile(Hex.decode(result.nextCmd!!.message)) + assertNotNull(serverMsg.msg) + result = clientSession.processMessage(Hex.encode(serverMsg.msg!!)) + allHaveIds.addAll(result.haveIds) + allNeedIds.addAll(result.needIds) + } + + // Client has 1-250 exclusively, server has 501-750 exclusively + assertEquals(250, allHaveIds.size) + assertEquals(250, allNeedIds.size) + assertTrue(rounds > 1, "Should require multiple rounds with frame limit") + } + + @Test + fun serverSession_processesMessages() { + val sharedEvents = + listOf( + makeEvent("a".repeat(64), 1000), + ) + val serverOnlyEvent = makeEvent("b".repeat(64), 2000) + val serverEvents = sharedEvents + serverOnlyEvent + + // Client initiates + val clientSession = NegentropySession("sub1", Filter(), sharedEvents) + val openCmd = clientSession.open() + + // Server processes via NegentropyServerSession + val serverSession = NegentropyServerSession("sub1", serverEvents) + val response = serverSession.processMessage(openCmd.initialMessage) + + assertNotNull(response) + assertEquals("NEG-MSG", response.label()) + assertEquals("sub1", response.subId) + assertTrue(response.message.isNotEmpty()) + } + + @Test + fun close_returnsCorrectCmd() { + val session = NegentropySession("sub1", Filter(), emptyList()) + val closeCmd = session.close() + + assertEquals("NEG-CLOSE", closeCmd.label()) + assertEquals("sub1", closeCmd.subId) + assertTrue(closeCmd.isValid()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/URIReferenceNormalizerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/URIReferenceNormalizerTest.kt new file mode 100644 index 0000000000..ca95fd1c35 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/URIReferenceNormalizerTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 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.utils.urldetector + +import com.vitorpamplona.quartz.utils.Rfc3986 +import kotlin.test.Test +import kotlin.test.assertEquals + +class URIReferenceNormalizerTest { + @Test + fun test_normalize() { + val uriRef1 = Rfc3986.normalize("hTTp://example.com/") + assertEquals("http://example.com/", uriRef1) + + val uriRef2 = Rfc3986.normalize("http://example.com/") + assertEquals("http://example.com/", uriRef2) + + val uriRef3 = Rfc3986.normalize("http://%75ser@example.com/") + assertEquals("http://user@example.com/", uriRef3) + + val uriRef4 = Rfc3986.normalize("http://%e3%83%a6%e3%83%bc%e3%82%b6%e3%83%bc@example.com/") + assertEquals("http://%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC@example.com/", uriRef4) + + val uriRef5 = Rfc3986.normalize("http://%65%78%61%6D%70%6C%65.com/") + assertEquals("http://example.com/", uriRef5) + + val uriRef6 = Rfc3986.normalize("http://%e4%be%8b.com/") + assertEquals("http://%E4%BE%8B.com/", uriRef6) + + val uriRef7 = Rfc3986.normalize("http://LOCALhost/") + assertEquals("http://localhost/", uriRef7) + + val uriRef8 = Rfc3986.normalize("http://example.com") + assertEquals("http://example.com/", uriRef8) + + val uriRef9 = Rfc3986.normalize("http://example.com/%61/%62/%63/") + assertEquals("http://example.com/a/b/c/", uriRef9) + + val uriRef10 = Rfc3986.normalize("http://example.com/%e3%83%91%e3%82%b9/") + assertEquals("http://example.com/%E3%83%91%E3%82%B9/", uriRef10) + + val uriRef11 = Rfc3986.normalize("http://example.com/a/b/c/../d/") + assertEquals("http://example.com/a/b/d/", uriRef11) + + val uriRef12 = Rfc3986.normalize("http://example.com:80/") + assertEquals("http://example.com/", uriRef12) + } + + @Test + fun moreTests() { + // From our conversation + assertEquals("http://%E4%BE%8B.com/", Rfc3986.normalize("http://%e4%be%8b.com/")) + + // Unreserved chars that should be decoded + assertEquals("http://example.com/~user/ABC", Rfc3986.normalize("http://example.com/%7euser/%41BC")) + + // Reserved chars that must NOT be decoded + assertEquals("http://example.com/path%2Fsegment?key%3Dvalue", Rfc3986.normalize("http://example.com/path%2Fsegment?key%3Dvalue")) + + // Mixed: some decodable, some not, lowercase hex + assertEquals("http://example.com/~%2Fa%3F", Rfc3986.normalize("http://EXAMPLE.COM/%7e%2f%61%3F")) + + // Userinfo + assertEquals("http://user%40name@example.com/", Rfc3986.normalize("http://user%40name@example.com/")) + + // With port and query and fragment + assertEquals("https://example.com:8080/foo~bar?a%3D1&b%2Bc#frag%23ment", Rfc3986.normalize("https://Example.COM:8080/foo%7ebar?a%3D1&b%2Bc#frag%23ment")) + + // IPv6 + assertEquals("http://[::1]:8080/path", Rfc3986.normalize("http://[::1]:8080/path")) + + // Already normalized + assertEquals("http://example.com/~user", Rfc3986.normalize("http://example.com/~user")) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index f3ab111d73..09e31365e0 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -26,6 +26,8 @@ import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage class MessageDeserializer : StdDeserializer(Message::class.java) { val eventDeserializer = EventDeserializer() @@ -101,6 +103,20 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { ) } + NegMsgMessage.LABEL -> { + NegMsgMessage( + subId = jp.nextTextValue(), + message = jp.nextTextValue(), + ) + } + + NegErrMessage.LABEL -> { + NegErrMessage( + subId = jp.nextTextValue(), + reason = jp.nextTextValue() ?: "", + ) + } + else -> { throw IllegalArgumentException("Message $type is not supported") } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 345b9d9c46..b0ca6557a3 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -24,6 +24,8 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage class MessageSerializer : StdSerializer(Message::class.java) { val eventSerializer = EventSerializer() @@ -75,6 +77,16 @@ class MessageSerializer : StdSerializer(Message::class.java) { is EoseMessage -> { gen.writeString(msg.subId) } + + is NegMsgMessage -> { + gen.writeString(msg.subId) + gen.writeString(msg.message) + } + + is NegErrMessage -> { + gen.writeString(msg.subId) + gen.writeString(msg.reason) + } } gen.writeEndArray() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt index f8265d0691..3b5a8d6222 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt @@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd class CommandDeserializer : StdDeserializer(Command::class.java) { val eventDeserializer = EventDeserializer() @@ -97,6 +100,33 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { ) } + NegOpenCmd.LABEL -> { + val subId = jp.nextTextValue() + jp.nextToken() + val filterObj: ObjectNode = jp.codec.readTree(jp) + val filter = ManualFilterDeserializer.fromJson(filterObj) + val initialMessage = jp.nextTextValue() + + NegOpenCmd( + subId = subId, + filter = filter, + initialMessage = initialMessage, + ) + } + + NegMsgCmd.LABEL -> { + NegMsgCmd( + subId = jp.nextTextValue(), + message = jp.nextTextValue(), + ) + } + + NegCloseCmd.LABEL -> { + NegCloseCmd( + subId = jp.nextTextValue(), + ) + } + else -> { throw IllegalArgumentException("Message $type is not supported") } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt index bacbb8bf13..97e57f0729 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt @@ -25,6 +25,9 @@ import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.vitorpamplona.quartz.nip01Core.jackson.EventSerializer import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer +import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd class CommandSerializer : StdSerializer(Command::class.java) { val eventSerializer = EventSerializer() @@ -65,6 +68,21 @@ class CommandSerializer : StdSerializer(Command::class.java) { } } + is NegOpenCmd -> { + gen.writeString(cmd.subId) + filterSerializer.serialize(cmd.filter, gen, provider) + gen.writeString(cmd.initialMessage) + } + + is NegMsgCmd -> { + gen.writeString(cmd.subId) + gen.writeString(cmd.message) + } + + is NegCloseCmd -> { + gen.writeString(cmd.subId) + } + else -> { null } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index b0ba1d6b7d..f8e0e3152c 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -38,19 +38,22 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import java.io.BufferedReader +import java.io.ByteArrayInputStream import java.io.InputStreamReader import java.io.PrintWriter import java.net.InetSocketAddress import java.net.Socket +import java.security.KeyStore import java.security.MessageDigest import java.security.SecureRandom -import java.security.cert.X509Certificate +import java.security.cert.CertificateFactory import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import javax.net.SocketFactory import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager /** @@ -87,25 +90,6 @@ class ElectrumXClient( private val requestId = AtomicInteger(0) private val serverMutexes = ConcurrentHashMap() - companion object { - private const val PROTOCOL_VERSION = "1.4" - - /** - * Namecoin names expire this many blocks after their last update. - * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 - * (~250 days at ~10 min/block). - */ - const val NAME_EXPIRE_DEPTH = 36_000 - - // Namecoin script opcodes - private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin - private const val OP_2DROP: Byte = 0x6d - private const val OP_DROP: Byte = 0x75 - private const val OP_RETURN: Byte = 0x6a - private const val OP_PUSHDATA1: Byte = 0x4c - private const val OP_PUSHDATA2: Byte = 0x4d - } - /** * Perform a name_show lookup against the given ElectrumX server. * @@ -167,6 +151,162 @@ class ElectrumXClient( throw NamecoinLookupException.ServersUnreachable(lastError) } + /** + * Test connectivity to a single ElectrumX server. + * + * Connects, negotiates protocol version, and optionally resolves a test + * name. Returns detailed results including response time, TLS version, + * and human-readable error messages. + * + * @param server The server to test + * @param testName Optional name to resolve (e.g. "d/testls") + * @return [ServerTestResult] with success/failure details + */ + suspend fun testServer( + server: ElectrumxServer, + testName: String? = "d/testls", + ): ServerTestResult = + withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + try { + val socket = createSocket(server) + socket.soTimeout = readTimeoutMs.toInt() + + var tlsVersion: String? = null + var serverCertPem: String? = null + var certFingerprint: String? = null + + if (socket is javax.net.ssl.SSLSocket) { + tlsVersion = socket.session.protocol + // Capture the server's leaf certificate for TOFU pinning + try { + val peerCerts = socket.session.peerCertificates + if (peerCerts.isNotEmpty() && peerCerts[0] is java.security.cert.X509Certificate) { + val x509 = peerCerts[0] as java.security.cert.X509Certificate + // PEM encode + val encoded = + java.util.Base64 + .getMimeEncoder(76, "\n".toByteArray()) + .encodeToString(x509.encoded) + serverCertPem = "-----BEGIN CERTIFICATE-----\n$encoded-----END CERTIFICATE-----" + // SHA-256 fingerprint + val digest = MessageDigest.getInstance("SHA-256").digest(x509.encoded) + certFingerprint = digest.joinToString(":") { "%02X".format(it) } + } + } catch (_: Exception) { + // Non-fatal — cert capture is best-effort + } + } + + val writer = PrintWriter(socket.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + + try { + // Negotiate protocol version + val versionReq = + buildRpcRequest( + "server.version", + listOf("AmethystNMC/0.1", PROTOCOL_VERSION), + ) + writer.println(versionReq) + val versionResponse = + reader.readLine() + ?: return@withContext ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Server returned empty response", + tlsVersion = tlsVersion, + ) + + // If a test name is provided, try to resolve it + if (testName != null) { + val nameScript = + buildNameIndexScript(testName.toByteArray(Charsets.US_ASCII)) + val scriptHash = electrumScriptHash(nameScript) + val historyReq = + buildRpcRequest( + "blockchain.scripthash.get_history", + listOf(scriptHash), + ) + writer.println(historyReq) + reader.readLine() // consume response + } + + val elapsed = System.currentTimeMillis() - startTime + ServerTestResult( + server = server, + success = true, + responseTimeMs = elapsed, + tlsVersion = tlsVersion, + serverCertPem = serverCertPem, + certFingerprint = certFingerprint, + ) + } finally { + runCatching { writer.close() } + runCatching { reader.close() } + runCatching { socket.close() } + } + } catch (e: java.net.ConnectException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Connection refused", + ) + } catch (e: java.net.SocketTimeoutException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Connection timed out after ${connectTimeoutMs / 1000}s", + ) + } catch (e: java.net.UnknownHostException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "Server unreachable (DNS resolution failed)", + ) + } catch (e: javax.net.ssl.SSLHandshakeException) { + val detail = + if (e.message?.contains("self-signed", ignoreCase = true) == true || + e.message?.contains("anchor", ignoreCase = true) == true + ) { + "TLS handshake failed (self-signed certificate rejected)" + } else { + "TLS handshake failed: ${e.message?.take(100) ?: "unknown error"}" + } + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = detail, + ) + } catch (e: javax.net.ssl.SSLException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "TLS error: ${e.message?.take(100) ?: "unknown"}", + ) + } catch (e: java.io.IOException) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = "I/O error: ${e.message?.take(100) ?: "unknown"}", + ) + } catch (e: Exception) { + ServerTestResult( + server = server, + success = false, + responseTimeMs = System.currentTimeMillis() - startTime, + error = e.message?.take(150) ?: "Unknown error", + ) + } + } + // ── internals ────────────────────────────────────────────────────── private fun connectAndQuery( @@ -461,41 +601,298 @@ class ElectrumXClient( if (!server.useSsl) return baseSocket // Upgrade to TLS over the already-connected (possibly proxied) socket. + // When usePinnedTrustStore is set, we use a pinned trust store that + // contains the known ElectrumX server certs plus system CAs. This is + // required because Samsung One UI 7 (Android 16) and GrapheneOS + // reject connections that use a no-op "trust-all" X509TrustManager. val sslFactory = - if (server.trustAllCerts) { - trustAllSslFactory() + if (server.host.endsWith(".onion")) { + // .onion addresses: prefer the pinned factory (the .onion server's + // cert is already in PINNED_ELECTRUMX_CERTS). Fall back to trust-all + // only if the pinned handshake fails — this keeps compatibility with + // .onion servers whose certs aren't pinned yet, while avoiding a + // blanket trust-all that hardened TLS stacks (GrapheneOS, Samsung + // Knox) may reject at the Conscrypt/BoringSSL layer. + cachedPinnedSslFactory() + } else if (server.usePinnedTrustStore) { + cachedPinnedSslFactory() } else { SSLSocketFactory.getDefault() as SSLSocketFactory } - return sslFactory.createSocket(baseSocket, server.host, server.port, true) + val sslSocket: Socket + try { + sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true) + } catch (e: javax.net.ssl.SSLHandshakeException) { + if (server.host.endsWith(".onion")) { + // Pinned factory failed for .onion — fall back to trust-all. + // This is safe: Tor provides E2E authentication via the onion + // address, and the proxied socket bypasses Knox/GrapheneOS + // trust-all rejection in practice. + val fallbackSocket = onionSslFactory().createSocket(baseSocket, server.host, server.port, true) + if (fallbackSocket is javax.net.ssl.SSLSocket) { + val supported = fallbackSocket.supportedProtocols + val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (modern.isNotEmpty()) { + fallbackSocket.enabledProtocols = modern.toTypedArray() + } + } + return fallbackSocket + } + throw e + } + + // Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS) + // may negotiate TLS 1.0/1.1 by default for raw socket upgrades. + if (sslSocket is javax.net.ssl.SSLSocket) { + val supported = sslSocket.supportedProtocols + val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" } + if (modern.isNotEmpty()) { + sslSocket.enabledProtocols = modern.toTypedArray() + } + } + + return sslSocket } /** - * Create an SSLSocketFactory that accepts any certificate. - * Used for servers with self-signed certificates. + * Fallback SSLSocketFactory for .onion addresses when the pinned + * factory fails (e.g. cert rotated, unknown .onion server). + * + * Only used as a last resort after cachedPinnedSslFactory() throws + * SSLHandshakeException. This is safe because: + * 1. The connection is already end-to-end encrypted by Tor. + * 2. The onion address IS the server's identity proof (public key hash). + * 3. Proxied sockets via Tor SOCKS typically bypass OEM trust-all + * rejection (Samsung Knox, GrapheneOS hardened Conscrypt). + * + * Note: GrapheneOS or future Android versions may reject trust-all + * TrustManagers even for proxied sockets. If this fallback stops + * working, the .onion server's cert should be added to + * PINNED_ELECTRUMX_CERTS (it's already there for the known server). */ - private fun trustAllSslFactory(): SSLSocketFactory { - val trustAllCerts = + private fun onionSslFactory(): SSLSocketFactory { + val trustAll = arrayOf( object : X509TrustManager { override fun checkClientTrusted( - chain: Array, + chain: Array, authType: String, ) {} override fun checkServerTrusted( - chain: Array, + chain: Array, authType: String, ) {} - override fun getAcceptedIssuers(): Array = arrayOf() + override fun getAcceptedIssuers(): Array = arrayOf() }, ) - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, trustAllCerts, SecureRandom()) + val ctx = + try { + SSLContext.getInstance("TLSv1.2") + } catch (_: Exception) { + SSLContext.getInstance("TLS") + } + ctx.init(null, trustAll, SecureRandom()) + return ctx.socketFactory + } + + /** User-supplied PEM certificates for custom servers (TOFU-pinned). */ + private val dynamicCerts = mutableListOf() + + /** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */ + @Volatile + private var pinnedFactory: SSLSocketFactory? = null + + private fun cachedPinnedSslFactory(): SSLSocketFactory { + pinnedFactory?.let { return it } + synchronized(this) { + pinnedFactory?.let { return it } + return buildPinnedSslFactory().also { pinnedFactory = it } + } + } + + /** + * Add a PEM-encoded certificate to the dynamic trust store. + * Typically called after the user confirms a cert fingerprint via + * the "Test Connection" flow in settings. + * + * Invalidates the cached factory so the next connection picks it up. + */ + fun addPinnedCert(pem: String) { + synchronized(this) { + dynamicCerts.add(pem) + pinnedFactory = null // force rebuild + } + } + + /** + * Replace all dynamic certs (e.g. loaded from preferences on startup). + */ + fun setDynamicCerts(pems: List) { + synchronized(this) { + dynamicCerts.clear() + dynamicCerts.addAll(pems) + pinnedFactory = null + } + } + + /** + * Build an SSLSocketFactory that trusts the pinned ElectrumX server + * certificates plus the system CA store. + * + * Previous versions used a "trust-all" TrustManager, but Samsung + * devices running One UI 7 (Android 16) silently reject connections + * that use a no-op X509TrustManager. Pinning the known self-signed + * certs avoids this while maintaining security. + * + * Also handles OEM-specific quirks: + * - Xiaomi MIUI/HyperOS: KeyStore.getDefaultType() may return unexpected + * types; we try the default first, then fall back to "PKCS12". + * - OnePlus ColorOS: some versions require explicit TLSv1.2 protocol. + * - All OEMs: SSLContext("TLSv1.2") is preferred over ("TLS") which may + * resolve to TLS 1.0 on older Conscrypt forks. + */ + private fun buildPinnedSslFactory(): SSLSocketFactory { + val ks = + try { + KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } + } catch (_: Exception) { + // Fallback for Xiaomi devices where getDefaultType() returns an unsupported type + KeyStore.getInstance("PKCS12").apply { load(null, null) } + } + + val cf = CertificateFactory.getInstance("X.509") + + // Load hardcoded + dynamic pinned certificates into the keystore + val allCerts = PINNED_ELECTRUMX_CERTS + dynamicCerts + for ((index, pem) in allCerts.withIndex()) { + try { + val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII))) + ks.setCertificateEntry("electrumx_$index", cert) + } catch (_: Exception) { + // Skip malformed certs — the remaining ones may still work + } + } + + // Also load system CA certificates so that servers with real certs work too + val systemTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + systemTmf.init(null as KeyStore?) // null = system default + val systemTm = systemTmf.trustManagers.filterIsInstance().firstOrNull() + if (systemTm != null) { + for ((index, issuer) in systemTm.acceptedIssuers.withIndex()) { + try { + ks.setCertificateEntry("system_$index", issuer) + } catch (_: Exception) { + // Some OEMs return certs that can't be re-inserted; skip + } + } + } + + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(ks) + + // Prefer TLSv1.2 explicitly — SSLContext.getInstance("TLS") can resolve + // to TLS 1.0 on some OEM Conscrypt forks (Xiaomi, OnePlus). + val sslContext = + try { + SSLContext.getInstance("TLSv1.2") + } catch (_: Exception) { + SSLContext.getInstance("TLS") + } + sslContext.init(null, tmf.trustManagers, SecureRandom()) return sslContext.socketFactory } + companion object { + private const val PROTOCOL_VERSION = "1.4" + + /** + * Namecoin names expire this many blocks after their last update. + * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 + * (~250 days at ~10 min/block). + */ + const val NAME_EXPIRE_DEPTH = 36_000 + + // Namecoin script opcodes + private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin + private const val OP_2DROP: Byte = 0x6d + private const val OP_DROP: Byte = 0x75 + private const val OP_RETURN: Byte = 0x6a + private const val OP_PUSHDATA1: Byte = 0x4c + private const val OP_PUSHDATA2: Byte = 0x4d + + /** + * PEM-encoded certificates for the well-known Namecoin ElectrumX servers. + * + * These are self-signed certificates that cannot be verified by the + * system CA store. We pin them explicitly so that connections succeed + * on devices with strict TLS enforcement (e.g. Samsung One UI 7). + * + * To update: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM` + * For .onion: `python3 -c "import socks,ssl,socket,base64; s=socks.socksocket(); s.set_proxy(socks.SOCKS5,'127.0.0.1',9050); s.connect(('HOST',PORT)); ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE; ss=ctx.wrap_socket(s); print(base64.encodebytes(ss.getpeercert(True)).decode())"` + */ + private val PINNED_ELECTRUMX_CERTS = + listOf( + // electrumx.testls.space:50002 — expires 2027-05-04 + // Also covers the .onion hidden service (same operator, same cert): + // i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002 + // SHA-256: 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:A0:EA:7A:99:2D:F7:0F:05:7E:9B:CD:50:36:C7:79:9C + """ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTAeFw0yMjA1MDUwNjIzNDFaFw0yNzA1MDQwNjIzNDFaMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO4H ++PKCdiiz3jNOA77aAmS2YaU7eOQ8ZGliEVr/PlLcgF5gmthb2DI6iK4KhC1ad34G +1n9IhkXPhkVJ94i8wB3uoTBlA7mI5h59m01yhzSkJAoYoU/i6DM9ipbakqWFCTEp +P+yE216NTU5MbYwThZdRSAIIABe9RyIliMSidyrwHvKBLfnJPFScghW6rhBWN7PG +PA8k0MFGzf+HXbpnV/jAvz08ZC34qiBIjkJrTgh49JweyoZKdppyJcH4UbkslJ2t +YUJR3oURBvrPj+D7TwLVRbX36ul7r4+dP3IjgmljsSAHDK4N/PfWrCBdlj9Pc1Cp +yX+ZDh8X2NrL4ukHoVMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeVj6VZNmY/Vb +nhzrC7xBSHqVWQ1wkLOClLsdvgKP8cFFJuUoCMQU5bPMi7nWnkfvvsIKH4Eibk5K +fqiA9jVsY0FHvQ8gP3KMk1LVuUf/sTcRe5itp3guBOSk/zXZUD5tUz/oRk3k+rdc +MsInqhomjNy/dqYmD6Wm4DNPjZh6fWy+AVQKVNOI2t4koaVdpoi8Uv8h4gFGPbdI +sVmtoGiIGkKNIWum+6mnF6PfynNrLk+ztH4TrdacVNeoJUPYEAxOuesWXFy3H4r+ +HKBqA4xAzyjgKLPqoWnjSu7gxj1GIjBhnDxkM6wUOnDq8A0EqxR+A17OcXW9sZ2O +2ZIVwmtnyA== +-----END CERTIFICATE----- + """.trimIndent(), + // nmc2.bitcoins.sk:57002 / 46.229.238.187:57002 — expires 2030-10-22 + """ +-----BEGIN CERTIFICATE----- +MIID+TCCAuGgAwIBAgIUdmJGukmfPvqmAYpTfuGcjRoYHJ8wDQYJKoZIhvcNAQEL +BQAwgYsxCzAJBgNVBAYTAlNLMREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwK +QnJhdGlzbGF2YTEUMBIGA1UECgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIu +Yml0Y29pbnMuc2sxIzAhBgkqhkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3Jn +MB4XDTIwMTAyNDE5MjQzOVoXDTMwMTAyMjE5MjQzOVowgYsxCzAJBgNVBAYTAlNL +MREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwKQnJhdGlzbGF2YTEUMBIGA1UE +CgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIuYml0Y29pbnMuc2sxIzAhBgkq +hkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3JnMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAzBUkZNDfaz7kc28l5tDKohJjekWmz1ynzfGx3ZLsqOZE +c+kNfcMaWU+zT/j0mV6pX6KSH7G9pPAku+8PRdKRq+d63wiJDEjGSaFztQWKW6L1 +vTxgCK5gu+Eir3BkTagJObsrLKS+T6qH610/3+btGgoR3lunB5TzCgB/9oQanjDW +zjg2CwmxgR5Iw1Eqfenx7zkSK33FSXSF2SvbUs1Atj2oPU4DLivyrx0RaUmaPemn +cmcpnax+py4pQeB6dJWU1INhzXt3hTJRyoqsSGY3vCECIKIBIkh8GsYjAX4z+Y9y +6pJx0da2b88qPWdsoxaIMvrQiuWknDrSJwAyw2Yd8QIDAQABo1MwUTAdBgNVHQ4E +FgQUT2J83B2/9jxGGdFeWrxMohTzHNwwHwYDVR0jBBgwFoAUT2J83B2/9jxGGdFe +WrxMohTzHNwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAsbxX +wN8tZaXOybImMZCQS7zfxmKl2IAcqu+R01KPfnIfrFqXPsGDDl3rYLkwh1O4/hYQ +NKNW9KTxoJxuBmAkm7EXQQh1XUUzajdEDqDBVRyvR0Z2MdMYnMSAiiMXMl2wUZnc +QXYftBo0HbtfsaJjImQdDjmlmRPSzE/RW6iUe+1cesKBC7e8nVf69Yu/fxO4m083 +VWwAstlWJfk1GyU7jzVc8svealg/oIiDoOMe6CFSLx1BDv2FeHSpRdqd3fn+AC73 +bK2N2smrHUOQnFijuiFw3WOrjERi0eMhjVNfVu9W9ZYa/Wd6SdIzV55LbG+NpmSf +5W7ix41hRvdT6cTAJA== +-----END CERTIFICATE----- + """.trimIndent(), + ) + } + private fun buildRpcRequest( method: String, params: List, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt index d4eff30ad1..b37a516173 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt @@ -74,7 +74,7 @@ class CountResultHllSerializationTest { assertEquals(100, result.count) assertTrue(result.approximate) assertNotNull(result.hll) - assertTrue(hll.contentEquals(result.hll!!)) + assertTrue(hll.contentEquals(result.hll)) } @Test @@ -100,7 +100,7 @@ class CountResultHllSerializationTest { assertEquals(original.count, deserialized.count) assertNotNull(deserialized.hll) - assertTrue(hll.contentEquals(deserialized.hll!!)) + assertTrue(hll.contentEquals(deserialized.hll)) } @Test @@ -115,7 +115,7 @@ class CountResultHllSerializationTest { val result = CountResultKSerializer.deserializeFromElement(jsonObject) assertEquals(42, result.count) assertNotNull(result.hll) - assertEquals(256, result.hll!!.size) - assertEquals(5, result.hll!![0].toInt() and 0xFF) + assertEquals(256, result.hll.size) + assertEquals(5, result.hll[0].toInt() and 0xFF) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt new file mode 100644 index 0000000000..48efb19ad6 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2025 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.nip77Negentropy + +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Nip77SerializationTest { + // ========================================================================= + // NEG-MSG Message (relay-to-client) Tests + // ========================================================================= + + @Test + fun serializeNegMsgMessage_matchesJackson() { + val msg = NegMsgMessage("neg-sub1", "abcdef0123456789") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeNegMsgMessage_jackson() { + val json = """["NEG-MSG","neg-sub1","abcdef0123456789"]""" + val deserialized = JacksonMapper.fromJsonToMessage(json) + + assertTrue(deserialized is NegMsgMessage) + assertEquals("neg-sub1", deserialized.subId) + assertEquals("abcdef0123456789", deserialized.message) + } + + @Test + fun deserializeNegMsgMessage_kotlin() { + val json = """["NEG-MSG","neg-sub1","abcdef0123456789"]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is NegMsgMessage) + assertEquals("neg-sub1", deserialized.subId) + assertEquals("abcdef0123456789", deserialized.message) + } + + @Test + fun negMsgMessage_crossDeserialization() { + val msg = NegMsgMessage("neg-sub1", "abcdef0123456789") + + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertTrue(kotlinDeserialized is NegMsgMessage) + assertEquals(msg.subId, kotlinDeserialized.subId) + assertEquals(msg.message, kotlinDeserialized.message) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertTrue(jacksonDeserialized is NegMsgMessage) + assertEquals(msg.subId, (jacksonDeserialized as NegMsgMessage).subId) + assertEquals(msg.message, jacksonDeserialized.message) + } + + // ========================================================================= + // NEG-ERR Message (relay-to-client) Tests + // ========================================================================= + + @Test + fun serializeNegErrMessage_matchesJackson() { + val msg = NegErrMessage("neg-sub1", "blocked: query too large") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeNegErrMessage_jackson() { + val json = """["NEG-ERR","neg-sub1","blocked: query too large"]""" + val deserialized = JacksonMapper.fromJsonToMessage(json) + + assertTrue(deserialized is NegErrMessage) + assertEquals("neg-sub1", deserialized.subId) + assertEquals("blocked: query too large", deserialized.reason) + } + + @Test + fun deserializeNegErrMessage_kotlin() { + val json = """["NEG-ERR","neg-sub1","closed: timeout"]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is NegErrMessage) + assertEquals("neg-sub1", deserialized.subId) + assertEquals("closed: timeout", deserialized.reason) + } + + @Test + fun negErrMessage_crossDeserialization() { + val msg = NegErrMessage("neg-sub1", "blocked: too many records") + + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertTrue(kotlinDeserialized is NegErrMessage) + assertEquals(msg.reason, (kotlinDeserialized as NegErrMessage).reason) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertTrue(jacksonDeserialized is NegErrMessage) + assertEquals(msg.reason, (jacksonDeserialized as NegErrMessage).reason) + } + + // ========================================================================= + // NEG-OPEN Command (client-to-relay) Tests + // ========================================================================= + + @Test + fun serializeNegOpenCmd_matchesJackson() { + val filter = Filter(kinds = listOf(1), since = 1000L) + val cmd = NegOpenCmd("neg-sub1", filter, "61abcdef") + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeNegOpenCmd_jackson() { + val json = """["NEG-OPEN","neg-sub1",{"kinds":[1],"since":1000},"61abcdef"]""" + val deserialized = JacksonMapper.fromJsonToCommand(json) + + assertTrue(deserialized is NegOpenCmd) + assertEquals("neg-sub1", deserialized.subId) + assertEquals(listOf(1), deserialized.filter.kinds) + assertEquals(1000L, deserialized.filter.since) + assertEquals("61abcdef", deserialized.initialMessage) + } + + @Test + fun deserializeNegOpenCmd_kotlin() { + val json = """["NEG-OPEN","neg-sub1",{"kinds":[1],"since":1000},"61abcdef"]""" + val deserialized = KotlinSerializationMapper.fromJsonToCommand(json) + + assertTrue(deserialized is NegOpenCmd) + assertEquals("neg-sub1", deserialized.subId) + assertEquals(listOf(1), deserialized.filter.kinds) + assertEquals("61abcdef", deserialized.initialMessage) + } + + @Test + fun negOpenCmd_crossDeserialization() { + val filter = Filter(kinds = listOf(1, 7), limit = 100) + val cmd = NegOpenCmd("neg-sub1", filter, "61aabbccdd") + + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToCommand(jacksonJson) + assertTrue(kotlinDeserialized is NegOpenCmd) + assertEquals(cmd.subId, kotlinDeserialized.subId) + assertEquals(cmd.initialMessage, kotlinDeserialized.initialMessage) + + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + val jacksonDeserialized = JacksonMapper.fromJsonToCommand(kotlinJson) + assertTrue(jacksonDeserialized is NegOpenCmd) + assertEquals(cmd.subId, (jacksonDeserialized as NegOpenCmd).subId) + assertEquals(cmd.initialMessage, jacksonDeserialized.initialMessage) + } + + // ========================================================================= + // NEG-MSG Command (client-to-relay) Tests + // ========================================================================= + + @Test + fun serializeNegMsgCmd_matchesJackson() { + val cmd = NegMsgCmd("neg-sub1", "aabbccdd") + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeNegMsgCmd_jackson() { + val json = """["NEG-MSG","neg-sub1","aabbccdd"]""" + val deserialized = JacksonMapper.fromJsonToCommand(json) + + assertTrue(deserialized is NegMsgCmd) + assertEquals("neg-sub1", deserialized.subId) + assertEquals("aabbccdd", deserialized.message) + } + + @Test + fun deserializeNegMsgCmd_kotlin() { + val json = """["NEG-MSG","neg-sub1","aabbccdd"]""" + val deserialized = KotlinSerializationMapper.fromJsonToCommand(json) + + assertTrue(deserialized is NegMsgCmd) + assertEquals("neg-sub1", deserialized.subId) + assertEquals("aabbccdd", deserialized.message) + } + + // ========================================================================= + // NEG-CLOSE Command (client-to-relay) Tests + // ========================================================================= + + @Test + fun serializeNegCloseCmd_matchesJackson() { + val cmd = NegCloseCmd("neg-sub1") + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeNegCloseCmd_jackson() { + val json = """["NEG-CLOSE","neg-sub1"]""" + val deserialized = JacksonMapper.fromJsonToCommand(json) + + assertTrue(deserialized is NegCloseCmd) + assertEquals("neg-sub1", deserialized.subId) + } + + @Test + fun deserializeNegCloseCmd_kotlin() { + val json = """["NEG-CLOSE","neg-sub1"]""" + val deserialized = KotlinSerializationMapper.fromJsonToCommand(json) + + assertTrue(deserialized is NegCloseCmd) + assertEquals("neg-sub1", deserialized.subId) + } + + // ========================================================================= + // Validation Tests + // ========================================================================= + + @Test + fun negOpenCmd_isValid() { + val valid = NegOpenCmd("sub1", Filter(), "61abcd") + assertTrue(valid.isValid()) + + val invalidSubId = NegOpenCmd("", Filter(), "61abcd") + assertTrue(!invalidSubId.isValid()) + + val invalidMsg = NegOpenCmd("sub1", Filter(), "") + assertTrue(!invalidMsg.isValid()) + } + + @Test + fun negMsgCmd_isValid() { + val valid = NegMsgCmd("sub1", "abcd") + assertTrue(valid.isValid()) + + val invalidSubId = NegMsgCmd("", "abcd") + assertTrue(!invalidSubId.isValid()) + } + + @Test + fun negCloseCmd_isValid() { + val valid = NegCloseCmd("sub1") + assertTrue(valid.isValid()) + + val invalid = NegCloseCmd("") + assertTrue(!invalid.isValid()) + } + + @Test + fun negMsgMessage_label() { + assertEquals("NEG-MSG", NegMsgMessage("sub1", "msg").label()) + } + + @Test + fun negErrMessage_label() { + assertEquals("NEG-ERR", NegErrMessage("sub1", "error").label()) + } +}