Merge branch 'main' into claude/add-negentropy-support-PcTpR

This commit is contained in:
Vitor Pamplona
2026-03-27 08:15:24 -04:00
committed by GitHub
93 changed files with 3073 additions and 744 deletions
@@ -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,15 +167,30 @@ 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 namecoinResolver =
NamecoinNameResolver(
electrumxClient =
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 by
lazy {
Log.d("AppModules", "Namecoin Resolver Init")
NamecoinNameResolver(
electrumxClient = electrumXClient,
serverListProvider = {
// User-configured custom servers take priority
namecoinPrefs.customServersOrNull
@@ -177,21 +201,33 @@ class AppModules(
}
},
)
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
}
// 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 =
val otsResolverBuilder by
lazy {
Log.d("AppModules", "OtsResolverBuilder Init")
TorAwareOkHttpOtsResolverBuilder(
roleBasedHttpClientBuilder::okHttpClientForMoney,
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
otsBlockHeightCache,
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) {
@@ -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<TopFilter>(PrefKeys.DEFAULT_HOME_FOLLOW_LIST) ?: TopFilter.AllFollows
val defaultStoriesFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST) ?: TopFilter.Global
val defaultNotificationFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST) ?: TopFilter.Global
val defaultDiscoveryFollowList = parseOrNull<TopFilter>(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<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestIndexRelayList = parseEventOrNull<IndexerRelayListEvent>(PrefKeys.LATEST_INDEX_RELAY_LIST)
val latestRelayFeedsList = parseEventOrNull<RelayFeedsListEvent>(PrefKeys.LATEST_RELAY_FEEDS_LIST)
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val latestChannelList = parseEventOrNull<ChannelListEvent>(PrefKeys.LATEST_CHANNEL_LIST)
val latestCommunityList = parseEventOrNull<CommunityListEvent>(PrefKeys.LATEST_COMMUNITY_LIST)
val latestHashtagList = parseEventOrNull<HashtagListEvent>(PrefKeys.LATEST_HASHTAG_LIST)
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
val latestTrustProviderList = parseEventOrNull<TrustProviderListEvent>(PrefKeys.LATEST_TRUST_PROVIDER_LIST)
val latestPaymentTargets = parseEventOrNull<PaymentTargetsEvent>(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<TopFilter>(defaultHomeFollowListStr) ?: TopFilter.AllFollows }
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(defaultDiscoveryFollowListStr) ?: TopFilter.Global }
val zapPaymentRequestServer = async { parseOrNull<Nip47WalletConnect.Nip47URI>(zapPaymentRequestServerStr) }
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val pendingAttestations = async { parseOrNull<Map<HexKey, String>>(pendingAttestationsStr) ?: mapOf() }
val latestUserMetadata = async { parseEventOrNull<MetadataEvent>(latestUserMetadataStr) }
val latestContactList = async { parseEventOrNull<ContactListEvent>(latestContactListStr) }
val latestDmRelayList = async { parseEventOrNull<ChatMessageRelayListEvent>(latestDmRelayListStr) }
val latestNip65RelayList = async { parseEventOrNull<AdvertisedRelayListEvent>(latestNip65RelayListStr) }
val latestSearchRelayList = async { parseEventOrNull<SearchRelayListEvent>(latestSearchRelayListStr) }
val latestIndexRelayList = async { parseEventOrNull<IndexerRelayListEvent>(latestIndexRelayListStr) }
val latestRelayFeedsList = async { parseEventOrNull<RelayFeedsListEvent>(latestRelayFeedsListStr) }
val latestBlockedRelayList = async { parseEventOrNull<BlockedRelayListEvent>(latestBlockedRelayListStr) }
val latestTrustedRelayList = async { parseEventOrNull<TrustedRelayListEvent>(latestTrustedRelayListStr) }
val latestMuteList = async { parseEventOrNull<MuteListEvent>(latestMuteListStr) }
val latestPrivateHomeRelayList = async { parseEventOrNull<PrivateOutboxRelayListEvent>(latestPrivateHomeRelayListStr) }
val latestAppSpecificData = async { parseEventOrNull<AppSpecificDataEvent>(latestAppSpecificDataStr) }
val latestChannelList = async { parseEventOrNull<ChannelListEvent>(latestChannelListStr) }
val latestCommunityList = async { parseEventOrNull<CommunityListEvent>(latestCommunityListStr) }
val latestHashtagList = async { parseEventOrNull<HashtagListEvent>(latestHashtagListStr) }
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
async {
parseOrNull<Map<String, Long>>(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 <reified T : Any> SharedPreferences.parseOrNull(key: String): T? {
val value = getString(key, null)
private inline fun <reified T : Any> 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 <reified T> SharedPreferences.parseEventOrNull(key: String): T? {
val value = getString(key, null)
private inline fun <reified T> 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
}
}
@@ -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<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val otsResolverBuilder: OtsResolverBuilder,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
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<Event, Set<NormalizedRelayUrl>>? {
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<Event, Set<NormalizedRelayUrl>>? {
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,
@@ -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
}
@@ -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<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val contentResolverFn: () -> ContentResolver,
val otsResolverBuilder: OtsResolverBuilder,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
val client: INostrClient,
) {
@@ -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)
}
}
}
@@ -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<Event>()
@@ -96,7 +96,7 @@ class OtsState(
Base64.getEncoder().encodeToString(
OtsEvent.stamp(
id,
otsResolver.build(),
otsResolver(),
),
),
)
@@ -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<Nip47WalletConnect.Nip47URINorm?>,
@@ -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) {
@@ -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<NoteState> = pinList.flow().metadata.stateFlow
fun getPinList(): PinListEvent? = pinList.event as? PinListEvent
fun pinnedEvents(note: Note): List<EventBookmark> {
val noteEvent = note.event as? PinListEvent
return noteEvent?.pinnedEvents() ?: emptyList()
}
@OptIn(FlowPreview::class)
val pinnedNotes: StateFlow<List<EventBookmark>> =
getPinListFlow()
.map { noteState ->
pinnedEvents(noteState.note)
}.onStart {
emit(pinnedEvents(pinList))
}.debounce(100)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val pinnedEventIdSet: StateFlow<Set<String>> =
pinnedNotes
.map { pins ->
pins.map { it.eventId }.toSet()
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(FlowPreview::class)
val pinnedNotesList: StateFlow<List<Note>> =
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,
)
}
}
@@ -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<NamecoinSettings> = _settings
init {
scope.launch {
_settings.tryEmit(loadFromDisk() ?: NamecoinSettings.DEFAULT)
}
}
/** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */
val current: NamecoinSettings get() = _settings.value
@@ -99,6 +103,46 @@ 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<String> = loadPinnedCertsFromDisk()
private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList())
private suspend fun savePinnedCerts(certs: List<String>) {
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<String> =
try {
val prefs = context.sharedPreferencesDataStore.data.first()
val certsJson = prefs[KEY_PINNED_CERTS]
if (certsJson != null) {
json.decodeFromString<List<String>>(certsJson)
} else {
emptyList()
}
} catch (_: Exception) {
emptyList()
}
// ── Internal ───────────────────────────────────────────────────────
@@ -51,7 +51,7 @@ class FeedTopNavFilterState(
val feedFilterListName: MutableStateFlow<TopFilter>,
val kind3Follows: StateFlow<Kind3FollowListState.Kind3Follows>,
val allFollows: StateFlow<MergedFollowListsState.AllFollows>,
val locationFlow: StateFlow<LocationState.LocationResult>,
val locationFlow: () -> StateFlow<LocationState.LocationResult>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
@@ -78,7 +78,7 @@ class FeedTopNavFilterState(
}
TopFilter.AroundMe -> {
AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays)
AroundMeFeedFlow(locationFlow(), followsRelays, proxyRelays)
}
TopFilter.Chess -> {
@@ -142,6 +142,7 @@ class BroadcastTracker {
}
}
try {
client.subscribe(subscription)
val finalBroadcast =
@@ -182,7 +183,6 @@ class BroadcastTracker {
resultCollector.await()
}
client.unsubscribe(subscription)
resultChannel.close()
// Remove from active, emit to completed
@@ -191,6 +191,9 @@ class BroadcastTracker {
}
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
} finally {
client.unsubscribe(subscription)
}
}
/**
@@ -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<Uri> {
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
@@ -64,7 +64,7 @@ class ImageLoaderSetup {
app: Context,
diskCache: () -> DiskCache,
memoryCache: () -> MemoryCache,
blossomServerResolver: BlossomServerResolver,
blossomServerResolver: () -> BlossomServerResolver,
callFactory: (url: String) -> Call.Factory,
) {
SingletonImageLoader.setUnsafe(
@@ -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,
)
}
}
@@ -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,
)
}
@@ -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(
@@ -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<Int> {
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(
@@ -39,6 +39,12 @@ private var resourceCacheLanguage: String? = null
// Caches most common icons in the app to avoid using disk
private val iconCache = LruCache<Int, LruCache<Int, Painter>>(30)
fun resourceCacheInit() {
resourceCache
resourceCacheLanguage
iconCache
}
fun checkLanguage(currentLanguage: String) {
if (resourceCacheLanguage == null) {
resourceCacheLanguage = currentLanguage
@@ -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() {
@@ -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),
@@ -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<ToastMsg?>(null)
@@ -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
@@ -144,17 +140,23 @@ fun AppNavigation(
) {
val nav = rememberNav()
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
val isTabPagerRoute =
navBackStackEntry?.destination?.let { dest ->
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: false
val drawerGesturesEnabled =
!isTabPagerRoute ||
nav.drawerState.isOpen ||
nav.drawerState.targetValue != nav.drawerState.currentValue
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
BuildNavigation(accountViewModel, nav)
}
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) {
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,
@@ -197,9 +199,9 @@ fun AppNavigation(
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(nav) }
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
@@ -267,7 +269,7 @@ fun AppNavigation(
GeoHashPostScreen(
geohash = it.geohash,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
attachment = it.attachment,
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
@@ -290,7 +292,7 @@ fun AppNavigation(
HashtagPostScreen(
hashtag = it.hashtag,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
attachment = it.attachment,
replyId = it.replyTo,
quoteId = it.quote,
draftId = it.draft,
@@ -303,7 +305,7 @@ fun AppNavigation(
ReplyCommentPostScreen(
replyId = it.replyTo,
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
attachment = it.attachment,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
@@ -314,7 +316,7 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewProduct> {
NewProductScreen(
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
attachment = it.attachment,
quoteId = it.quote,
draftId = it.draft,
accountViewModel,
@@ -334,7 +336,7 @@ fun AppNavigation(
composableFromBottomArgs<Route.NewShortNote> {
ShortNotePostScreen(
message = it.message,
attachment = it.attachment?.ifBlank { null }?.toUri(),
attachment = it.attachment,
baseReplyToId = it.baseReplyTo,
quoteId = it.quote,
forkId = it.fork,
@@ -357,14 +359,6 @@ fun AppNavigation(
)
}
}
}
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
DisplayNotifyMessages(accountViewModel, nav)
DisplayCrashMessages(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
}
@Composable
@@ -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,31 +703,27 @@ fun IconRow(
tint: Color,
onClick: () -> Unit,
) {
val title = stringRes(title)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(
IconRowModifier.clickable(
onClick = onClick,
),
) {
Row(
modifier = IconRowModifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterRes(icon, iconReference),
contentDescription = stringRes(title),
contentDescription = title,
modifier = Size22Modifier,
tint = tint,
)
Text(
modifier = IconRowTextModifier,
text = stringRes(title),
text = title,
fontSize = Font18SP,
)
}
}
}
@Composable
@@ -619,33 +733,29 @@ fun IconRow(
tint: Color,
onClick: () -> Unit,
) {
val title = stringRes(title)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(
onClickLabel = stringRes(title),
IconRowModifier.clickable(
onClickLabel = title,
onClick = onClick,
),
) {
Row(
modifier = IconRowModifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = stringRes(title),
modifier = Size22Modifier.padding(end = 4.dp),
contentDescription = title,
modifier = Size22ModifierWith4Padding,
tint = tint,
)
Text(
modifier = IconRowTextModifier,
text = stringRes(title),
text = title,
fontSize = Font18SP,
)
}
}
}
@Composable
@@ -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,
)
}
@@ -123,7 +123,7 @@ fun LoadOts(
withContext(Dispatchers.IO) {
LocalCache.findEarliestOtsForNote(
note = noteStatus?.note ?: note,
otsVerifCache = Amethyst.instance.otsVerifCache,
otsVerifCacheBuilder = { Amethyst.instance.otsVerifCache },
)
}
@@ -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)
}
}
}
@@ -82,9 +82,17 @@ class UserSuggestionState(
.map(::userSearchTermOrNull)
.map { prefix ->
if (prefix != null) {
// 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) {
@@ -96,7 +104,6 @@ class UserSuggestionState(
}
user
}
}
}.getOrNull()
} else if (prefix.startsWithAny(userUriPrefixes)) {
runCatching {
@@ -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,
@@ -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)
@@ -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)))
@@ -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,
)
}
}
@@ -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) }
@@ -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<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: 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)
@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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>(LocationState.LocationResult.Loading),
nwcFilterAssembler = nwcFilters,
otsResolverBuilder = EmptyOtsResolverBuilder,
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(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>(LocationState.LocationResult.Loading),
nwcFilterAssembler = nwcFilters,
otsResolverBuilder = EmptyOtsResolverBuilder,
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(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
}
@@ -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 },
),
)
@@ -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,
)
}
}
}
}
@@ -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<Note>() {
override fun feedKey(): String =
account.pinState.pinnedNotesList.value
.hashCode()
.toString()
override fun feed(): List<Note> = account.pinState.pinnedNotesList.value
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = PinnedNotesFeedViewModel(account) as T
}
}
@@ -125,6 +125,7 @@ fun BookmarkGroupItemOptionsMenu(
isFollowingAuthor = false,
isPrivateBookmarkNote = false,
isPublicBookmarkNote = false,
isPinnedNote = false,
isLoggedUser = false,
isSensitive = false,
showSensitiveContent = null,
@@ -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)
@@ -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)
@@ -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)
@@ -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)))
@@ -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)
@@ -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)))
@@ -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)))
@@ -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)))
@@ -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)
@@ -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)
@@ -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)
@@ -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(
@@ -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)
@@ -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(
@@ -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) },
@@ -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,
@@ -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)}")
}
@@ -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,
)
}
}
}
@@ -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<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex
override fun feed(): List<Note> {
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()
}
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = UserProfilePinnedNotesFeedViewModel(user, account) as T
}
}
@@ -95,9 +95,18 @@ class SearchBarViewModel(
searchTerm
.debounce(400)
.mapLatest { term ->
// 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) {
@@ -109,7 +118,6 @@ class SearchBarViewModel(
}
user
}
}
}.getOrNull()
} else if (term.startsWithAny(userUriPrefixes)) {
runCatching {
@@ -91,7 +91,7 @@ fun SearchScreen(
factory =
SearchBarViewModel.Factory(
accountViewModel.account,
accountViewModel.nip05Client,
accountViewModel.nip05ClientBuilder(),
),
)
@@ -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)
}
},
)
}
}
@@ -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<List<ServerTestResult>>(emptyList()) }
var lastTestTimestamp by remember { mutableStateOf<Long?>(null) }
// Certs discovered during testing that need user confirmation
var pendingCerts by remember { mutableStateOf<List<PendingCertPin>>(emptyList()) }
// Which cert is currently shown in the confirmation dialog
var confirmingCert by remember { mutableStateOf<PendingCertPin?>(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<ServerTestResult>()
val newCerts = mutableListOf<PendingCertPin>()
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<ServerTestResult>,
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(
@@ -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(
@@ -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,
),
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@@ -61,6 +61,8 @@
<string name="unauthorized_exception_description">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</string>
<string name="signer_not_found_exception">Podepisovatel nenalezen</string>
<string name="signer_not_found_exception_description">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.</string>
<string name="signer_illegal_state_exception">Podepisovatel se choval neočekávaně</string>
<string name="signer_illegal_state_exception_description">Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli.</string>
<string name="zaps">Zapy</string>
<string name="view_count">Počet zobrazení</string>
<string name="boost">Zvýšení</string>
@@ -446,6 +448,8 @@
<string name="poll_zap_value_max">Maximální zaps</string>
<string name="poll_consensus_threshold">Konsensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Jedna možnost</string>
<string name="poll_multiple_choice">Více možností</string>
<string name="poll_closing_date_time">Datum a čas ukončení ankety</string>
<string name="poll_closing_in">Anketa končí za %1$s</string>
<string name="poll_closing_time">Uzavřít po</string>
@@ -485,6 +489,9 @@
<string name="zap_type_anonymous_explainer">Příjemce a veřejnost neví, kdo platbu poslal</string>
<string name="zap_type_nonzap">Né Zap</string>
<string name="zap_type_nonzap_explainer">Žádná stopa v Nostr, pouze v Lightning</string>
<string name="post_anonymously">Anonymní</string>
<string name="post_anonymously_explainer">Odeslat jako novou jednorázovou identitu. Váš účet nebude s touto odpovědí spojen.</string>
<string name="anonymous_reply_warning">Tato odpověď bude odeslána z nové anonymní identity</string>
<string name="file_server">Souborový server</string>
<string name="file_server_description">Zvolte server pro nahrání tohoto souboru</string>
<string name="zap_forward_lnAddress">LnAddress nebo @Uživatel</string>
@@ -1324,6 +1331,7 @@
<string name="feed_group_hashtags">Hashtagy</string>
<string name="feed_group_communities">Komunity</string>
<string name="feed_group_lists">Seznamy</string>
<string name="feed_group_relays">Relé</string>
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
<string name="private_message">Soukromá zpráva</string>
<string name="public_message">Veřejná zpráva</string>
@@ -1560,6 +1568,7 @@
<string name="kind_shorts">Krátká videa</string>
<string name="kind_voice_msg">Hlasová zpráva</string>
<string name="kind_voice_reply">Hlasová odpověď</string>
<string name="kind_web_bookmark">Webová záložka</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Začněte se skvělým feedem tím, že budete sledovat stejné lidi jako někdo, komu důvěřujete.</string>
<string name="import_follow_list">Importovat seznam sledovaných</string>
@@ -1675,4 +1684,20 @@
<string name="event_sync_date_filter_all_time">Vždy</string>
<string name="event_sync_date_filter_last_sync">Poslední synchronizace: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Od poslední synchronizace</string>
<string name="web_bookmarks">Webové záložky</string>
<string name="web_bookmarks_empty">Zatím žádné webové záložky. Klepněte na + pro přidání.</string>
<string name="web_bookmark_add_title">Přidat webovou záložku</string>
<string name="web_bookmark_edit_title">Upravit webovou záložku</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://example.com</string>
<string name="web_bookmark_title_label">Název</string>
<string name="web_bookmark_title_placeholder">Název záložky</string>
<string name="web_bookmark_description_label">Popis</string>
<string name="web_bookmark_description_placeholder">Krátký popis</string>
<string name="web_bookmark_tags_label">Tagy (oddělené čárkou)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Uložit</string>
<string name="web_bookmark_delete">Smazat</string>
<string name="web_bookmark_delete_confirm">Smazat tuto webovou záložku?</string>
<string name="web_bookmark_open_url">Otevřít URL</string>
</resources>
@@ -61,6 +61,8 @@
<string name="unauthorized_exception_description">Der Signierer hat die erforderliche Entschlüsselung nicht autorisiert. Aktiviere NIP-44-Entschlüsselung in deiner Signierer-App und versuche es erneut</string>
<string name="signer_not_found_exception">Signierer nicht gefunden</string>
<string name="signer_not_found_exception_description">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.</string>
<string name="signer_illegal_state_exception">Signierer hat sich unerwartet verhalten</string>
<string name="signer_illegal_state_exception_description">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.</string>
<string name="zaps">Zaps</string>
<string name="view_count">Aufrufe</string>
<string name="boost">Boost</string>
@@ -452,6 +454,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="poll_zap_value_max">Maximaler Zap-Betrag</string>
<string name="poll_consensus_threshold">Konsens</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Einzelauswahl</string>
<string name="poll_multiple_choice">Mehrfachauswahl</string>
<string name="poll_closing_date_time">Schließdatum und -zeit</string>
<string name="poll_closing_in">Umfrage endet in %1$s</string>
<string name="poll_closing_time">Schließen nach</string>
@@ -491,6 +495,9 @@ anz der Bedingungen ist erforderlich</string>
<string name="zap_type_anonymous_explainer">Empfänger und die Öffentlichkeit wissen nicht, wer die Zahlung gesendet hat</string>
<string name="zap_type_nonzap">Keine Zap</string>
<string name="zap_type_nonzap_explainer">Keine Spur in Nostr, nur in Lightning</string>
<string name="post_anonymously">Anonym</string>
<string name="post_anonymously_explainer">Als neue Wegwerfidentität posten. Dein Konto wird nicht mit dieser Antwort verknüpft.</string>
<string name="anonymous_reply_warning">Diese Antwort wird von einer neuen anonymen Identität veröffentlicht</string>
<string name="file_server">Dateiserver</string>
<string name="file_server_description">Wählen Sie einen Server zum Hochladen dieser Datei</string>
<string name="zap_forward_lnAddress">LnAddress oder @Benutzer</string>
@@ -1329,6 +1336,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="feed_group_hashtags">Hash-Tags</string>
<string name="feed_group_communities">Gemeinschaften</string>
<string name="feed_group_lists">Listen</string>
<string name="feed_group_relays">Relais</string>
<string name="temporary_account">Beim Sperren des Geräts abmelden</string>
<string name="private_message">Private Nachricht</string>
<string name="public_message">Öffentliche Nachricht</string>
@@ -1565,6 +1573,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Sprachnachricht</string>
<string name="kind_voice_reply">Sprachantwort</string>
<string name="kind_web_bookmark">Web-Lesezeichen</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Starte mit einem großartigen Feed, indem du dieselben Personen folgst wie jemand, dem du vertraust.</string>
<string name="import_follow_list">Folgeliste importieren</string>
@@ -1680,4 +1689,20 @@ anz der Bedingungen ist erforderlich</string>
<string name="event_sync_date_filter_all_time">Gesamter Zeitraum</string>
<string name="event_sync_date_filter_last_sync">Letzte Synchronisierung: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Seit letzter Synchronisierung</string>
<string name="web_bookmarks">Web-Lesezeichen</string>
<string name="web_bookmarks_empty">Noch keine Web-Lesezeichen. Tippe auf + um eines hinzuzufügen.</string>
<string name="web_bookmark_add_title">Web-Lesezeichen hinzufügen</string>
<string name="web_bookmark_edit_title">Web-Lesezeichen bearbeiten</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://beispiel.com</string>
<string name="web_bookmark_title_label">Titel</string>
<string name="web_bookmark_title_placeholder">Lesezeichen-Titel</string>
<string name="web_bookmark_description_label">Beschreibung</string>
<string name="web_bookmark_description_placeholder">Eine kurze Beschreibung</string>
<string name="web_bookmark_tags_label">Tags (kommagetrennt)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Speichern</string>
<string name="web_bookmark_delete">Löschen</string>
<string name="web_bookmark_delete_confirm">Dieses Web-Lesezeichen löschen?</string>
<string name="web_bookmark_open_url">URL öffnen</string>
</resources>
+50 -12
View File
@@ -61,6 +61,8 @@
<string name="unauthorized_exception_description">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</string>
<string name="signer_not_found_exception">Nem található az aláíró</string>
<string name="signer_not_found_exception_description">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.</string>
<string name="signer_illegal_state_exception">Az aláíró hibásan működött</string>
<string name="signer_illegal_state_exception_description">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.</string>
<string name="zaps">Zap-ek</string>
<string name="view_count">Megtekintések száma</string>
<string name="boost">Megtolás</string>
@@ -241,7 +243,7 @@
<string name="already_have_an_account">Már van Nostr-fiókja?</string>
<string name="create_a_new_account">Új fiók létrehozása</string>
<string name="generate_a_new_key">Új kulcs előállítása</string>
<string name="loading_feed">Hírfolyam betöltése</string>
<string name="loading_feed">Hírfolyam betöltése</string>
<string name="loading_account">Fiók betöltése…</string>
<string name="error_loading_replies">"Hiba a válaszok betöltésekor: "</string>
<string name="try_again">Próbálja újra</string>
@@ -395,7 +397,7 @@
<string name="bookmark_list_posts_btn_label">Bejegyzések megtekintése</string>
<string name="bookmark_list_articles_btn_label">Cikkek megtekintése</string>
<string name="bookmark_list_links_btn_label">Hivakozások megtekintése</string>
<string name="bookmark_list_hashtags_btn_label">Hashtagek megtekintése</string>
<string name="bookmark_list_hashtags_btn_label">Kulcsszavak megtekintése</string>
<string name="bookmark_list_feed_empty_msg">Még nincs egyetlen könyvjelzőlistája sem. Koppintson az „Új” gombra, hogy létrehozzon egyet.</string>
<string name="private_posts_label">Privát bejegyzések</string>
<string name="private_posts_count">Privát bejegyzések (%1$s)</string>
@@ -489,6 +491,9 @@
<string name="zap_type_anonymous_explainer">A kedvezményezett és a nyilvánosság nem tudja, hogy ki küldte a fizetést</string>
<string name="zap_type_nonzap">Nem Zap</string>
<string name="zap_type_nonzap_explainer">Nostr-ban nyoma sincs, csak a Lightning-ban</string>
<string name="post_anonymously">Névtelen</string>
<string name="post_anonymously_explainer">Közzététel új, eldobható profillal. Az Ön saját fiókja nem lesz köthető ehhez a válaszhoz.</string>
<string name="anonymous_reply_warning">Ez a válasz egy új, névtelen profilból lesz közzétéve</string>
<string name="file_server">Fájlkiszolgáló</string>
<string name="file_server_description">Válasszon ki egy kiszolgálót a fájl feltöltéséhez neki:</string>
<string name="zap_forward_lnAddress">Ln-cím vagy @Felhasználó</string>
@@ -522,8 +527,8 @@
<string name="yes">Igen</string>
<string name="no">Nem</string>
<string name="follow_list_selection">Követési lista</string>
<string name="follow_list_kind3follows">Követettek bejegyzései</string>
<string name="follow_list_kind3follows_users_only">Összes követett felhasználó</string>
<string name="follow_list_kind3follows">Minden ami követett</string>
<string name="follow_list_kind3follows_users_only">Minden követett felhasználó</string>
<string name="follow_list_kind3_follows_users_only">Alapértelmezett követési lista</string>
<string name="follow_list_kind3follows_proxy">Követés proxyn keresztül</string>
<string name="follow_list_aroundme">Közelben lévők bejegyzései</string>
@@ -838,8 +843,8 @@
<string name="geohash_explainer">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</string>
<string name="geohash_exclusive">Helyszín-alapú bejegyzés</string>
<string name="geohash_exclusive_explainer">Csak a helyszín követői láthatják. Az általános követők nem fogják látni.</string>
<string name="hashtag_exclusive">Hashtag-exkluzív bejegyzés</string>
<string name="hashtag_exclusive_explainer">Csak a hashtag követői fogják látni, de az Ön általános követői viszont nem.</string>
<string name="hashtag_exclusive">Kulcsszó-exkluzív bejegyzés</string>
<string name="hashtag_exclusive_explainer">Csak a kulcsszó követői fogják látni, de az Ön általános követői viszont nem.</string>
<string name="loading_location">Helyszín betöltése…</string>
<string name="lack_location_permissions">A helyszín-meghatározás nincs engedélyezve</string>
<string name="add_sensitive_content_explainer">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</string>
@@ -1183,7 +1188,7 @@
<string name="outbox_relays_title">Átjátszók a kimenő üzenetkhez</string>
<string name="outbox_relays_not_found">Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez</string>
<string name="outbox_relays_not_found_description">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. </string>
<string name="outbox_relays_not_found_editing">Adjon meg 13 á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</string>
<string name="outbox_relays_not_found_editing">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</string>
<string name="outbox_relays_not_found_examples">Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="inbox_relays_title">Átjátszók a bejövő üzenetkhez</string>
<string name="inbox_relays_not_found">Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához</string>
@@ -1328,9 +1333,10 @@
<string name="people_list_label">Felhasználók</string>
<string name="select_list_to_filter">Szempont kiválasztása a hírfolyam szűréséhez</string>
<string name="feed_group_feeds">Hírfolyamok</string>
<string name="feed_group_hashtags">Hashtagek</string>
<string name="feed_group_hashtags">Kulcsszavak</string>
<string name="feed_group_communities">Közösségek</string>
<string name="feed_group_lists">Listák</string>
<string name="feed_group_relays">Átjátszók</string>
<string name="temporary_account">Kijelentkeztetés az eszköz zárolása esetén</string>
<string name="private_message">Privát üzenet</string>
<string name="public_message">Nyílvános üzenet</string>
@@ -1341,7 +1347,7 @@
<string name="share_video">Videó megosztása…</string>
<string name="unable_to_share_video">Nem sikerült megosztani a videót, próbálja meg újra később…</string>
<string name="downloading_video_for_sharing">Videó letöltése…</string>
<string name="search_by_hashtag">Hashtag keresése: #%1$s</string>
<string name="search_by_hashtag">Kulcsszó keresése: #%1$s</string>
<string name="dont_translate_from">Innentől NE fordítsa le</string>
<string name="dont_translate_from_description">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.</string>
<string name="translate_to">Fordítás erre:</string>
@@ -1454,7 +1460,7 @@
<string name="kind_blossom_auth">Blossom-hitelesítés</string>
<string name="kind_broadcast_relays">Közvetítési átjátszók</string>
<string name="kind_bookmark_list">Könyvjelzőlista</string>
<string name="kind_day_appointment">Napi bejegyzés</string>
<string name="kind_day_appointment">Napi időpont</string>
<string name="kind_calendar">Naptár</string>
<string name="kind_appointment">Találkozó</string>
<string name="kind_appt_rsvp">Időpont-visszaigazolás</string>
@@ -1502,7 +1508,7 @@
<string name="kind_git_repo">Git tároló</string>
<string name="kind_git_reply">Git válasz</string>
<string name="kind_zap_goals">Zap-célok</string>
<string name="kind_hashtag_follows">Hashtag-követések</string>
<string name="kind_hashtag_follows">Kulcsszó-követések</string>
<string name="kind_highlights">Kiemelések</string>
<string name="kind_http_auth">Http-hitelesítés</string>
<string name="kind_index_relay_list">Indexelő átjátszók listája</string>
@@ -1567,13 +1573,14 @@
<string name="kind_shorts">Rövidek</string>
<string name="kind_voice_msg">Hangüzenet</string>
<string name="kind_voice_reply">Hangos válasz</string>
<string name="kind_web_bookmark">Webes könyvjelző</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Kezdje egy remek hírfolyammal, követve azokat az embereket, akikben megbízik.</string>
<string name="import_follow_list">Követési lista importálása</string>
<string name="select_users_to_follow">Felhasználók kiválasztása a követéshez</string>
<string name="profile_to_import_from">Importálandó profil</string>
<string name="name_search_npub1_alice_example_com">keresés, npub1…, aliz@pelda.hu</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Támogatja az npub, nprofile, NIP-05, hex és namecoin (.bit, d/, id/) formátumokat</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Támogatja az npub, nprofile, NIP-05, hex és Namecoin (.bit) formátumokat</string>
<string name="look_up_follow_list">Követési lista keresése</string>
<string name="tip">Borravaló</string>
<string name="accounts_found">%1$d felhasználói fiók megtalálva</string>
@@ -1595,6 +1602,21 @@
<string name="select_all">Összes kijelölése</string>
<string name="uptime">Üzemidő: %1$d%%</string>
<string name="namecoin_settings">Namecoin-beállítások</string>
<string name="namecoin_test_connection">Kapcsolat tesztelése</string>
<string name="namecoin_testing">Kiszolgálók tesztelése…</string>
<string name="namecoin_test_success">Kapcsolódva</string>
<string name="namecoin_test_failed">Sikertelen</string>
<string name="namecoin_test_results">Teszteredmények</string>
<string name="namecoin_diagnostics">Diagnosztika</string>
<string name="namecoin_last_test">Utoljára tesztelve</string>
<string name="namecoin_device_info">Eszközinformáció</string>
<string name="namecoin_tls_info">TLS-információ</string>
<string name="namecoin_no_test_yet">Még nem volt futtatva a tesztelés</string>
<string name="namecoin_response_time">%d ms</string>
<string name="namecoin_pin_cert_title">Megbízik a kiszolgáló tanúsítványában?</string>
<string name="namecoin_pin_cert_body">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.</string>
<string name="namecoin_pin_cert_accept">Elfogadás</string>
<string name="namecoin_pin_cert_reject">Elutasítás</string>
<string name="event_sync_title">Átjátszószinkronizálás</string>
<string name="event_sync_section">Átjátszószinkronizálás</string>
<string name="event_sync_section_explainer">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.</string>
@@ -1682,4 +1704,20 @@
<string name="event_sync_date_filter_all_time">Összes</string>
<string name="event_sync_date_filter_last_sync">Utoljára szinkronizálva: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Utolsó szinkronizálás óta</string>
<string name="web_bookmarks">Webes könyvjelzők</string>
<string name="web_bookmarks_empty">Még nincsenek webes könyvjelzők. Koppintson a „+” gombra a hozzáadáshoz.</string>
<string name="web_bookmark_add_title">Webes könyvjelző hozzáadása</string>
<string name="web_bookmark_edit_title">Webes könyvjelző szerkesztése</string>
<string name="web_bookmark_url_label">Webcím</string>
<string name="web_bookmark_url_placeholder">https://pelda.hu</string>
<string name="web_bookmark_title_label">Cím</string>
<string name="web_bookmark_title_placeholder">Könyvjelző neve</string>
<string name="web_bookmark_description_label">Leírás</string>
<string name="web_bookmark_description_placeholder">Egy rövid leírás</string>
<string name="web_bookmark_tags_label">Címkék (vesszővel elválasztva)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Mentés</string>
<string name="web_bookmark_delete">Törlés</string>
<string name="web_bookmark_delete_confirm">Törli ezt a webes könyvjelzőt?</string>
<string name="web_bookmark_open_url">Webcím megnyitása</string>
</resources>
@@ -524,8 +524,8 @@
<string name="yes">Tak</string>
<string name="no">Nie</string>
<string name="follow_list_selection">Lista obserwowanych</string>
<string name="follow_list_kind3follows">Obserwowane</string>
<string name="follow_list_kind3follows_users_only">Wszystkie obserwowane</string>
<string name="follow_list_kind3follows">Wszystkie obserwacje</string>
<string name="follow_list_kind3follows_users_only">Obserwowane osoby</string>
<string name="follow_list_kind3_follows_users_only">Domyślna lista obserwowanych</string>
<string name="follow_list_kind3follows_proxy">Obserwowani przez proxy</string>
<string name="follow_list_aroundme">W pobliżu</string>
@@ -1504,7 +1504,7 @@
<string name="kind_git_repo">Repozytorium Git</string>
<string name="kind_git_reply">Odpowiedź Git</string>
<string name="kind_zap_goals">Cele Zap-a</string>
<string name="kind_hashtag_follows">Obserwacja hashtagów</string>
<string name="kind_hashtag_follows">Obserwowane hashtagi</string>
<string name="kind_highlights">Najważniejsze informacje</string>
<string name="kind_http_auth">Autoryzacja http</string>
<string name="kind_index_relay_list">Indeks listy transmiterów</string>
@@ -1569,6 +1569,7 @@
<string name="kind_shorts">Filmiki</string>
<string name="kind_voice_msg">Wiadomość głosowa</string>
<string name="kind_voice_reply">Odpowiedź głosowa</string>
<string name="kind_web_bookmark">Zakładka</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Stwórz świetny kanał, obserwując te same osoby, które obserwuje zaufana osoba.</string>
<string name="import_follow_list">Importuj listę obserwowanych</string>
@@ -1597,6 +1598,20 @@
<string name="select_all">Wybierz Wszystkie</string>
<string name="uptime">Czas działania %1$d%%</string>
<string name="namecoin_settings">Ustawienia Namecoin</string>
<string name="namecoin_test_connection">Test połączenia</string>
<string name="namecoin_testing">Testowanie serwerów…</string>
<string name="namecoin_test_success">Podłączony</string>
<string name="namecoin_test_results">Wyniki testu</string>
<string name="namecoin_diagnostics">Diagnostyka</string>
<string name="namecoin_last_test">Ostatni test</string>
<string name="namecoin_device_info">Informacje o urządzeniu</string>
<string name="namecoin_tls_info">Informacje o TLS</string>
<string name="namecoin_no_test_yet">Nie wykonano jeszcze testu</string>
<string name="namecoin_response_time">%dms</string>
<string name="namecoin_pin_cert_title">Zaufać certyfikatowi serwera?</string>
<string name="namecoin_pin_cert_body">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ń.</string>
<string name="namecoin_pin_cert_accept">Zaufaj</string>
<string name="namecoin_pin_cert_reject">Odrzuć</string>
<string name="event_sync_title">Synchronizacja Transmitera</string>
<string name="event_sync_section">Synchronizacja Transmitera</string>
<string name="event_sync_section_explainer">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.</string>
@@ -1684,4 +1699,20 @@
<string name="event_sync_date_filter_all_time">Cały czas</string>
<string name="event_sync_date_filter_last_sync">Ostatnia synchronizacja %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Od ostatniej synchronizacji</string>
<string name="web_bookmarks">Zakładki</string>
<string name="web_bookmarks_empty">Brak zakładek. Naciśnij + aby dodać nową zakładkę.</string>
<string name="web_bookmark_add_title">Dodaj zakładkę</string>
<string name="web_bookmark_edit_title">Edytuj zakładkę</string>
<string name="web_bookmark_url_label">Adres URL</string>
<string name="web_bookmark_url_placeholder">https://domena.pl</string>
<string name="web_bookmark_title_label">Tytuł</string>
<string name="web_bookmark_title_placeholder">Tytuł zakładki</string>
<string name="web_bookmark_description_label">Opis</string>
<string name="web_bookmark_description_placeholder">Krótki opis</string>
<string name="web_bookmark_tags_label">Tagi (oddzielone przecinkami)</string>
<string name="web_bookmark_tags_placeholder">nostr, technika, blog</string>
<string name="web_bookmark_save">Zapisz</string>
<string name="web_bookmark_delete">Usuń</string>
<string name="web_bookmark_delete_confirm">Usunąć tę zakładkę?</string>
<string name="web_bookmark_open_url">Otwórz adres URL</string>
</resources>
@@ -61,6 +61,8 @@
<string name="unauthorized_exception_description">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</string>
<string name="signer_not_found_exception">Assinador não encontrado</string>
<string name="signer_not_found_exception_description">O aplicativo de assinatura foi desinstalado? Verifique se ele está instalado e com esta conta. Saia e entre novamente se ele foi alterado.</string>
<string name="signer_illegal_state_exception">Assinador se comportou de forma inesperada</string>
<string name="signer_illegal_state_exception_description">O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador.</string>
<string name="zaps">Zaps</string>
<string name="view_count">Contagem de visualizações</string>
<string name="boost">Impulsionar</string>
@@ -446,6 +448,8 @@
<string name="poll_zap_value_max">Zap máximo</string>
<string name="poll_consensus_threshold">Consenso</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Escolha única</string>
<string name="poll_multiple_choice">Múltipla escolha</string>
<string name="poll_closing_date_time">Data e hora de encerramento da enquete</string>
<string name="poll_closing_in">Enquete encerra em %1$s</string>
<string name="poll_closing_time">Fechar depois</string>
@@ -485,6 +489,9 @@
<string name="zap_type_anonymous_explainer">Destinatário e o público não sabem quem enviou o pagamento</string>
<string name="zap_type_nonzap">Sem Zap</string>
<string name="zap_type_nonzap_explainer">Nenhum traço no Nostr, apenas na Lightning</string>
<string name="post_anonymously">Anônimo</string>
<string name="post_anonymously_explainer">Postar como uma nova identidade descartável. Sua conta não será vinculada a esta resposta.</string>
<string name="anonymous_reply_warning">Esta resposta será postada a partir de uma nova identidade anônima</string>
<string name="file_server">Servidor de arquivos</string>
<string name="file_server_description">Escolha um servidor para onde enviar este arquivo</string>
<string name="zap_forward_lnAddress">LnAddress ou @Usuário</string>
@@ -1324,6 +1331,7 @@
<string name="feed_group_hashtags">Hashtags</string>
<string name="feed_group_communities">Comunidades</string>
<string name="feed_group_lists">Listas</string>
<string name="feed_group_relays">Relés</string>
<string name="temporary_account">Terminar sessão no bloqueio do dispositivo</string>
<string name="private_message">Mensagem Privada</string>
<string name="public_message">Mensagem pública</string>
@@ -1560,6 +1568,7 @@
<string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Mensagem de voz</string>
<string name="kind_voice_reply">Resposta de voz</string>
<string name="kind_web_bookmark">Marcador Web</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Comece com um ótimo feed seguindo as mesmas pessoas que alguém em quem você confia.</string>
<string name="import_follow_list">Importar lista de seguidos</string>
@@ -1675,4 +1684,20 @@
<string name="event_sync_date_filter_all_time">Todo o período</string>
<string name="event_sync_date_filter_last_sync">Última sincronização: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Desde a última sincronização</string>
<string name="web_bookmarks">Marcadores Web</string>
<string name="web_bookmarks_empty">Nenhum marcador web ainda. Toque em + para adicionar.</string>
<string name="web_bookmark_add_title">Adicionar Marcador Web</string>
<string name="web_bookmark_edit_title">Editar Marcador Web</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://exemplo.com</string>
<string name="web_bookmark_title_label">Título</string>
<string name="web_bookmark_title_placeholder">Título do marcador</string>
<string name="web_bookmark_description_label">Descrição</string>
<string name="web_bookmark_description_placeholder">Uma breve descrição</string>
<string name="web_bookmark_tags_label">Tags (separadas por vírgula)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Salvar</string>
<string name="web_bookmark_delete">Excluir</string>
<string name="web_bookmark_delete_confirm">Excluir este marcador web?</string>
<string name="web_bookmark_open_url">Abrir URL</string>
</resources>
@@ -72,6 +72,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="unauthorized_exception_description">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</string>
<string name="signer_not_found_exception">Ne najdem podpisnika</string>
<string name="signer_not_found_exception_description">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.</string>
<string name="signer_illegal_state_exception">Napačno delovanje podpisnika</string>
<string name="signer_illegal_state_exception_description">Zunanji podpisnik je vrnil neobičajen odgovor. Morda gre za napako v aplikaciji Amethyst ali v podpisniku.</string>
<string name="zaps">Zapi</string>
<string name="view_count">Števec vpogledov</string>
<string name="boost">Pošlji naprej</string>
@@ -118,7 +120,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="new_channel">Nov kanal</string>
<string name="channel_name">Ime kanala</string>
<string name="my_awesome_group">Moja vrhunska skupina</string>
<string name="picture_url">Url slike</string>
<string name="picture_url">URL slike</string>
<string name="optional_picture_url">URL slike (Neobvezno)</string>
<string name="description">Opis</string>
<string name="no_description">Ne najdem opisa</string>
@@ -193,7 +195,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="voice_anonymize_title">Anonimiziraj</string>
<string name="voice_anonymize_description">Prilagodi višino tona svojega glasu: Opomba: osnovne spremembe višine tona lahko poslušalci potencialno razveljavijo.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uporabnik nima nastavljenega \"lightning\" naslova za sprejem satoshi-jev</string>
<string name="reply_here">"odgovori tukaj.. "</string>
<string name="reply_here">"odgovori tukaj "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopira ID zapiska v odložišče za deljenje v Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Kopiraj ID kanala (zapisek) v odložišče</string>
<string name="edits_the_channel_metadata">Uredi metapodatke kanala</string>
@@ -301,6 +303,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="nip_05">Nostr naslov t. i. Nip-05</string>
<string name="never">nikoli</string>
<string name="now">zdaj</string>
<string name="seconds">sekunde</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
@@ -387,7 +390,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="manual_zaps">Ročno razdeli zape</string>
<string name="bookmarks">Zaznamki</string>
<string name="bookmarks_title">Privzeti zaznamki</string>
<string name="bookmarks_explainer">Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev.</string>
<string name="bookmarks_explainer">Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev</string>
<string name="drafts">Osnutki</string>
<string name="private_bookmarks">Privatni zaznamki</string>
<string name="public_bookmarks">Javni zaznamki</string>
@@ -441,7 +444,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="wallet_connect_manual_config">Napredno: ročni vnos podatkov o povezavi</string>
<string name="quick_zap_amounts">Zneski za hitre zape</string>
<string name="quick_zap_amounts_explainer">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.</string>
<string name="zap_privacy_section">Zap zasebnost</string>
<string name="zap_privacy_section">Zasebnost zapov</string>
<string name="zap_type_section_explainer">Določa, kako je prikazana vaša identiteta, ko pošljete zap.</string>
<string name="wallet_connect_connect_app">Poveži denarnico</string>
<string name="see_relay_feed">Pogled v vsebino releja</string>
@@ -458,6 +461,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="poll_zap_value_max">Zap maksimum</string>
<string name="poll_consensus_threshold">Soglasje</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Ena izbira</string>
<string name="poll_multiple_choice">Več izbire</string>
<string name="poll_closing_date_time">Datum in čas konca glasovanja</string>
<string name="poll_closing_in">Anketa se zaključi %1$s</string>
<string name="poll_closing_time">Zapri po</string>
@@ -497,6 +502,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="zap_type_anonymous_explainer">Prejemnik in javnost ne vejo kdo je poslal plačilo</string>
<string name="zap_type_nonzap">Ni-Zap</string>
<string name="zap_type_nonzap_explainer">Brez sledi v Nostr, samo v Lightning</string>
<string name="post_anonymously">Anonimno</string>
<string name="post_anonymously_explainer">Objavite z novo začasno identiteto. Vaš račun ne bo povezan s tem odgovorom.</string>
<string name="anonymous_reply_warning">Ta odgovor bo objavljen z novo anonimno identiteto</string>
<string name="file_server">Datotečni strežnik</string>
<string name="file_server_description">Izberi strežnik za nalaganje te datoteke</string>
<string name="zap_forward_lnAddress">LnNaslov ali @Uporabnik</string>
@@ -753,7 +761,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="access_control">Nadzor dostopa</string>
<string name="minimum_pow">Minimalen PoW</string>
<string name="auth">Auth</string>
<string name="auth_required">Potrebna avtentikacija</string>
<string name="auth_required">Potrebna je avtentikacija</string>
<string name="payment">Plačilo</string>
<string name="payment_required">Potrebno je plačilo</string>
<string name="max_message_length">Največja dolžina sporočila</string>
@@ -826,7 +834,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="gallery_style">Slog galerije profila</string>
<string name="gallery_style_description">Izberi slog galerije</string>
<string name="load_image">Naloži sliko</string>
<string name="spamming_users">Pošiljatelji nezaželjenih vsebin</string>
<string name="spamming_users">Pošiljatelji nezaželenih vsebin</string>
<string name="muted_button">Utišano. Klikni za vklop zvoka</string>
<string name="mute_button">Zvok je prižgan. Klikni da ga utišaš</string>
<string name="skip_back">Skoči nazaj za %d sekund</string>
@@ -1260,6 +1268,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Dokaz časovnega žiga</string>
<string name="ots_info_description">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.</string>
<string name="edit_article">Uredi članek</string>
<string name="edit_post">Uredi objavo</string>
<string name="proposal_to_edit">Prošnja za izboljšavo objave</string>
<string name="message_to_author">Povzetek sprememb</string>
@@ -1339,6 +1348,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="feed_group_hashtags">Ključniki</string>
<string name="feed_group_communities">Skupnosti</string>
<string name="feed_group_lists">Seznami</string>
<string name="feed_group_relays">Releji</string>
<string name="temporary_account">Odjava ob zaklepu naprave</string>
<string name="private_message">Zasebno sporočilo</string>
<string name="public_message">Javno sporočilo</string>
@@ -1575,6 +1585,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="kind_shorts">Kratki videoposnetki</string>
<string name="kind_voice_msg">Zvočno sporočilo</string>
<string name="kind_voice_reply">Zvočni odgovori</string>
<string name="kind_web_bookmark">Spletni zaznamek</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate.</string>
<string name="import_follow_list">Uvozi seznam sledenih</string>
@@ -1690,4 +1701,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="event_sync_date_filter_all_time">Vse do zdaj</string>
<string name="event_sync_date_filter_last_sync">Zadnja sinhronizacija: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Od zadnje sinhronizacije</string>
<string name="web_bookmarks">Spletni zaznamki</string>
<string name="web_bookmarks_empty">Seznam spletnih zaznamkov je prazen. Dodajte ga s pritiskom na +.</string>
<string name="web_bookmark_add_title">Dodaj spletni zaznamek</string>
<string name="web_bookmark_edit_title">Uredi spletni zaznamek</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://primer.si</string>
<string name="web_bookmark_title_label">Naslov</string>
<string name="web_bookmark_title_placeholder">Naslov zaznamka</string>
<string name="web_bookmark_description_label">Opis</string>
<string name="web_bookmark_description_placeholder">Kratek opis</string>
<string name="web_bookmark_tags_label">Oznake (Ločite z vejicami)</string>
<string name="web_bookmark_tags_placeholder">nostr, tehnika , blog</string>
<string name="web_bookmark_save">Shrani</string>
<string name="web_bookmark_delete">Izbriši</string>
<string name="web_bookmark_delete_confirm">Izbriši ta spletni zaznamek</string>
<string name="web_bookmark_open_url">Odpri URL</string>
</resources>
@@ -61,6 +61,8 @@
<string name="unauthorized_exception_description">Signatären har inte godkänt den dekryptering som krävs. Aktivera NIP-44-dekryptering i din signeringsapp och försök igen</string>
<string name="signer_not_found_exception">Signatör saknas</string>
<string name="signer_not_found_exception_description">Har signeringsappen avinstallerats? Kontrollera om den är installerad och innehåller det här kontot. Logga ut och in igen om den har ändrats.</string>
<string name="signer_illegal_state_exception">Signatären betedde sig oväntat</string>
<string name="signer_illegal_state_exception_description">Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären.</string>
<string name="zaps">Zaps</string>
<string name="view_count">Antal visningar</string>
<string name="boost">Boosta</string>
@@ -446,6 +448,8 @@
<string name="poll_zap_value_max">Maximal Zap</string>
<string name="poll_consensus_threshold">Konsensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Enkelt val</string>
<string name="poll_multiple_choice">Flerval</string>
<string name="poll_closing_date_time">Stängningsdatum och tid</string>
<string name="poll_closing_in">Omröstningen stänger om %1$s</string>
<string name="poll_closing_time">Avsluta efter</string>
@@ -485,6 +489,9 @@
<string name="zap_type_anonymous_explainer">Mottagaren och allmänheten vet inte vem som skickade betalningen</string>
<string name="zap_type_nonzap">Ingen Zap</string>
<string name="zap_type_nonzap_explainer">Inga spår i Nostr, bara i Lightning</string>
<string name="post_anonymously">Anonym</string>
<string name="post_anonymously_explainer">Publicera som en ny engångsidentitet. Ditt konto kommer inte att kopplas till detta svar.</string>
<string name="anonymous_reply_warning">Detta svar kommer att publiceras från en ny anonym identitet</string>
<string name="file_server">Fil Server</string>
<string name="file_server_description">Välj en server att ladda upp denna fil till</string>
<string name="zap_forward_lnAddress">LnAdress eller @Användare</string>
@@ -1323,6 +1330,7 @@
<string name="feed_group_hashtags">Hashtaggar</string>
<string name="feed_group_communities">Gemenskaper</string>
<string name="feed_group_lists">Listor</string>
<string name="feed_group_relays">Reläer</string>
<string name="temporary_account">Logga ut när enheten låses</string>
<string name="private_message">Privat meddelande</string>
<string name="public_message">Offentligt meddelande</string>
@@ -1559,6 +1567,7 @@
<string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Röstmeddelande</string>
<string name="kind_voice_reply">Röstsvar</string>
<string name="kind_web_bookmark">Webbbokmärke</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Kom igång med ett bra flöde genom att följa samma personer som någon du litar på.</string>
<string name="import_follow_list">Importera följarlista</string>
@@ -1674,4 +1683,20 @@
<string name="event_sync_date_filter_all_time">All tid</string>
<string name="event_sync_date_filter_last_sync">Senaste synkronisering: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">Sedan senaste synkronisering</string>
<string name="web_bookmarks">Webbbokmärken</string>
<string name="web_bookmarks_empty">Inga webbbokmärken ännu. Tryck på + för att lägga till.</string>
<string name="web_bookmark_add_title">Lägg till webbbokmärke</string>
<string name="web_bookmark_edit_title">Redigera webbbokmärke</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://exempel.se</string>
<string name="web_bookmark_title_label">Titel</string>
<string name="web_bookmark_title_placeholder">Bokmärkets titel</string>
<string name="web_bookmark_description_label">Beskrivning</string>
<string name="web_bookmark_description_placeholder">En kort beskrivning</string>
<string name="web_bookmark_tags_label">Taggar (kommaseparerade)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Spara</string>
<string name="web_bookmark_delete">Radera</string>
<string name="web_bookmark_delete_confirm">Radera detta webbbokmärke?</string>
<string name="web_bookmark_open_url">Öppna URL</string>
</resources>
+45 -10
View File
@@ -28,8 +28,8 @@
<string name="relay_icon">中继器图标</string>
<string name="unknown_author">未知作者</string>
<string name="copy_text">复制文本</string>
<string name="copy_user_pubkey">复制作者@npub</string>
<string name="copy_note_id">复制笔记ID</string>
<string name="copy_user_pubkey">复制作者公钥 ID</string>
<string name="copy_note_id">复制笔记 ID</string>
<string name="broadcast">广播</string>
<string name="timestamp_it">获取公开时间戳</string>
<string name="timestamp_pending">OpenTimestamps:待确认</string>
@@ -61,6 +61,8 @@
<string name="unauthorized_exception_description">签名器没有授权解密操作,请在签名器中授予 NIP-44 解密权限并重试。</string>
<string name="signer_not_found_exception">未找到签名器</string>
<string name="signer_not_found_exception_description">签名器被卸载?请检查是否已经安装了签名器以及其中是否存在该账户。变更签名器需要注销后重新登录。</string>
<string name="signer_illegal_state_exception">签名器行为异常</string>
<string name="signer_illegal_state_exception_description">外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。</string>
<string name="zaps">打闪</string>
<string name="view_count">浏览次数</string>
<string name="boost">提升</string>
@@ -70,7 +72,7 @@
<string name="original">原版</string>
<string name="quote">引用</string>
<string name="fork">复刻</string>
<string name="propose_an_edit">提议编辑</string>
<string name="propose_an_edit">出修改建</string>
<string name="new_amount_in_sats">新的聪金额</string>
<string name="add">添加</string>
<string name="replying_to">"回复 "</string>
@@ -114,7 +116,7 @@
<string name="about_us">"关于我们.. "</string>
<string name="what_s_on_your_mind">你在想什么?</string>
<string name="write_a_message">写一条消息…</string>
<string name="post">发布</string>
<string name="post">贴文</string>
<string name="save">保存</string>
<string name="create">创建</string>
<string name="rename">重命名</string>
@@ -183,7 +185,7 @@
<string name="voice_anonymize_description">更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
<string name="reply_here">"🔏在此回复… "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板以便于在 Nostr 中分享</string>
<string name="copy_channel_id_note_to_the_clipboard">复制频道ID(笔记)到剪贴板</string>
<string name="edits_the_channel_metadata">修改频道元数据</string>
<string name="join">加入</string>
@@ -196,7 +198,7 @@
<string name="mod_queue">Mod 队列</string>
<string name="notes">笔记</string>
<string name="replies">回复</string>
<string name="mutual">你的</string>
<string name="mutual">互动</string>
<string name="gallery">相册</string>
<string name="follows">"关注"</string>
<string name="reports">"举报"</string>
@@ -326,7 +328,7 @@
<string name="new_badge_award_notif">你收到了新的徽章奖励</string>
<string name="award_granted_to">徽章奖励授予</string>
<string name="copied_note_text_to_clipboard">文本已复制到剪贴板</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">复制作者的 @npub 到剪贴板</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">复制作者公钥 ID 到剪贴板</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">已复制笔记ID (@note1) 到剪贴板</string>
<string name="select_text_dialog_top">选择文本</string>
<string name="private_conversation_notification">"&lt;无法解密私密消息&gt;\n\n;你被 %1$s 和 %2$s 之间的私人/加密会话引用。"</string>
@@ -597,7 +599,7 @@
</string>
<string name="orbot_socks_port">Orbot Socks 端口</string>
<string name="use_internal_tor">启动 Tor</string>
<string name="use_internal_tor_explainer">使用内置的版本或者 Orbot</string>
<string name="use_internal_tor_explainer">使用内置 Tor 或者 Orbot</string>
<string name="tor_preset">Tor 和隐私预设</string>
<string name="tor_preset_explainer">快速修改下方的设置</string>
<string name="tor_use_onion_address">Onion 链接或中继地址</string>
@@ -1131,7 +1133,7 @@
<string name="reactions_settings_reorder">调整顺序</string>
<string name="reactions_settings_reply">回复</string>
<string name="reactions_settings_reply_description">回复此笔记</string>
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_boost">提升</string>
<string name="reactions_settings_boost_description">转发或引用此笔记</string>
<string name="reactions_settings_like">点赞</string>
<string name="reactions_settings_like_description">使用表情符号回应笔记</string>
@@ -1334,6 +1336,7 @@
<string name="feed_group_hashtags">话题标签</string>
<string name="feed_group_communities">社区</string>
<string name="feed_group_lists">列表</string>
<string name="feed_group_relays">中继</string>
<string name="temporary_account">当设备锁定时注销</string>
<string name="private_message">私信</string>
<string name="public_message">公开消息</string>
@@ -1570,6 +1573,7 @@
<string name="kind_shorts">短篇</string>
<string name="kind_voice_msg">语音消息</string>
<string name="kind_voice_reply">语音回复</string>
<string name="kind_web_bookmark">网络书签</string>
<string name="kind_wiki">维基</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">关注你信任的人所关注的人来开启优质的源。</string>
<string name="import_follow_list">导入关注列表</string>
@@ -1598,6 +1602,21 @@
<string name="select_all">全选</string>
<string name="uptime">%1$d%% 运行时间</string>
<string name="namecoin_settings">Namecoin 设置</string>
<string name="namecoin_test_connection">测试连接</string>
<string name="namecoin_testing">正在测试服务器…</string>
<string name="namecoin_test_success">已连接</string>
<string name="namecoin_test_failed">已失败</string>
<string name="namecoin_test_results">测试结果</string>
<string name="namecoin_diagnostics">诊断</string>
<string name="namecoin_last_test">上次测试</string>
<string name="namecoin_device_info">设备信息</string>
<string name="namecoin_tls_info">TLS 信息</string>
<string name="namecoin_no_test_yet">尚未运行测试</string>
<string name="namecoin_response_time">%d毫秒</string>
<string name="namecoin_pin_cert_title">信任服务器证书?</string>
<string name="namecoin_pin_cert_body">服务器 %1$s 提供的证书尚未在您的信任存储中。 验证下面的指纹与服务器运营者发布的内容匹配,然后选择是否信任它来进行未来连接。</string>
<string name="namecoin_pin_cert_accept">信任</string>
<string name="namecoin_pin_cert_reject">拒绝</string>
<string name="event_sync_title">中继同步</string>
<string name="event_sync_section">中继同步</string>
<string name="event_sync_section_explainer">在所有已知的中继重新发布您的事件,以保持您的发件箱、收件箱和私信中继是最新的。 需要 Wi-Fi - 这可能使用大量数据。</string>
@@ -1639,7 +1658,7 @@
<string name="dms">私信</string>
<string name="profiles">个人资料</string>
<string name="relay_settings_lower">中继设置</string>
<string name="last_seen">上次看见在 %1$s </string>
<string name="last_seen">%1$s 前活跃</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">连接中</string>
<string name="event_sync_status_downloading">下载中</string>
@@ -1685,4 +1704,20 @@
<string name="event_sync_date_filter_all_time">全部时间</string>
<string name="event_sync_date_filter_last_sync">上次同步: %1$s</string>
<string name="event_sync_date_filter_since_last_sync">自上次同步后</string>
<string name="web_bookmarks">网络书签</string>
<string name="web_bookmarks_empty">暂无网络书签。点击 + 添加一个。</string>
<string name="web_bookmark_add_title">添加网络书签</string>
<string name="web_bookmark_edit_title">编辑网络书签</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://example.com</string>
<string name="web_bookmark_title_label">标题</string>
<string name="web_bookmark_title_placeholder">书签标题</string>
<string name="web_bookmark_description_label">描述</string>
<string name="web_bookmark_description_placeholder">一段简短描述</string>
<string name="web_bookmark_tags_label">标签(以逗号分隔)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">保存</string>
<string name="web_bookmark_delete">删除</string>
<string name="web_bookmark_delete_confirm">删除该网络书签?</string>
<string name="web_bookmark_open_url">打开 URL</string>
</resources>
+20 -1
View File
@@ -418,6 +418,10 @@
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
<string name="pinned_notes">Pinned Notes</string>
<string name="pin_to_profile">Pin to Profile</string>
<string name="unpin_from_profile">Unpin from Profile</string>
<string name="bookmark_lists">Bookmark Lists</string>
<string name="bookmark_list_icon_label">Icon for bookmark list</string>
<string name="bookmark_list_creation_screen_title">New Bookmark List</string>
@@ -1827,7 +1831,7 @@
<string name="select_users_to_follow">Select Users to Follow</string>
<string name="profile_to_import_from">Profile to import from</string>
<string name="name_search_npub1_alice_example_com">search, npub1…, alice@example.com</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/)</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit)</string>
<string name="look_up_follow_list">Look Up Follow List</string>
<string name="tip">Tip</string>
<string name="accounts_found">%1$d accounts found</string>
@@ -1849,6 +1853,21 @@
<string name="select_all">Select All</string>
<string name="uptime">%1$d%% uptime</string>
<string name="namecoin_settings">Namecoin Settings</string>
<string name="namecoin_test_connection">Test Connection</string>
<string name="namecoin_testing">Testing servers…</string>
<string name="namecoin_test_success">Connected</string>
<string name="namecoin_test_failed">Failed</string>
<string name="namecoin_test_results">Test Results</string>
<string name="namecoin_diagnostics">Diagnostics</string>
<string name="namecoin_last_test">Last test</string>
<string name="namecoin_device_info">Device Info</string>
<string name="namecoin_tls_info">TLS Info</string>
<string name="namecoin_no_test_yet">No test run yet</string>
<string name="namecoin_response_time">%dms</string>
<string name="namecoin_pin_cert_title">Trust Server Certificate?</string>
<string name="namecoin_pin_cert_body">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.</string>
<string name="namecoin_pin_cert_accept">Trust</string>
<string name="namecoin_pin_cert_reject">Reject</string>
<string name="event_sync_title">Relay Sync</string>
<string name="event_sync_section">Relay Sync</string>
<string name="event_sync_section_explainer">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.</string>
@@ -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
@@ -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()
}
}
}
@@ -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()
@@ -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<T> :
ComposeSubscriptionManagerControls,
Subscribable<T> {
@@ -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<T : MutableQueryState>(
val scope: CoroutineScope,
) : ComposeSubscriptionManagerControls {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

-2
View File
@@ -55,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"
@@ -125,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" }
-7
View File
@@ -119,9 +119,6 @@ kotlin {
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)
}
@@ -173,7 +170,6 @@ kotlin {
dependencies {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
}
}
@@ -195,7 +191,6 @@ kotlin {
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
}
}
@@ -207,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)
}
@@ -225,7 +219,6 @@ kotlin {
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
}
}
@@ -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)
}
@@ -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),
)
@@ -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<Array<String>>,
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<EventBookmark> = 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<String>,
pin: EventBookmark,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PinListEvent {
val tags = mutableListOf<Array<String>>()
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, "")
}
}
}
@@ -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
}
@@ -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<String> =
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("^(?<firstsegment>\\/?[^/]*)(?<remaining>.*)$")
private const val DEFAULT_SCHEME = "https"
private val SCHEME_PORT_MAP: Map<String, Int> =
mapOf(
@@ -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"))
}
}
@@ -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<String, Mutex>()
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<TrustManager>(
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
chain: Array<java.security.cert.X509Certificate>,
authType: String,
) {}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
chain: Array<java.security.cert.X509Certificate>,
authType: String,
) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = 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<String>()
/** 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<String>) {
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<X509TrustManager>().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<Any>,
@@ -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)
}
}