diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 516903f50..a103377d8 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -54,7 +54,6 @@ Quartz exposes these as `api` (you get them transitively): | `fr.acinq.secp256k1:secp256k1-kmp-*` | Schnorr signing | | `com.github.anthonynsimon:rfc3986-normalizer` | Relay URL normalization | | `com.fasterxml.jackson.module:jackson-module-kotlin` | Event JSON parsing | -| `com.linkedin.urls:url-detector` | URL extraction from content | For Android, add to `build.gradle.kts`: ```kotlin diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index 7a17c0be5..e1d989521 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -92,7 +92,6 @@ dependencies { ### JVM + Android (`jvmAndroid`) - `com.github.anthonynsimon:rfc3986-normalizer` (URL normalization) - `com.fasterxml.jackson.module:jackson-module-kotlin` (JSON) -- `com.linkedin.urls:url-detector` (URL extraction) - `com.squareup.okhttp3:okhttp` (WebSocket) - `ru.gildor.coroutines:kotlin-coroutines-okhttp` - `nl.bommber:kchesslib` (NIP-64 chess, version pinned to 1.0.0) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fa1598e..7a6fc1505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,185 @@ +Adds support for creating and rendering NIP-85 Polls + +Adds support for Relay Feeds +- Adds support for NIP-51 favorite relay feeds +- Shows favorite relays in the top navigation filter +- Clicking wss:// links shows the global feed for that relay. +- New user account adds nostr.wine to favorite relay feeds + +Redesigns Media Player +- Redesigned player controls for videos, audios, and picture-in-picture. +- Adds our own buttons and indicators for the video playback +- Adds Music support with waveform animations +- Migrates to new Media3 content view frames +- Improved Picture in Picture actions +- Turn video controller creation into a flow to fix playback lifecycle issues +- Adds support for uploading audio + +Adds support for NIP events (kind 30817) + +Adds support for NIP-52 Calendar appointments + +Adds support for NIP-39 External Identities with kind 10011 + +Adds support for NIP-66 Relay Monitor and discovery support to Quartz + +Adds support for NIP-C0 Code Snippets to Quartz + +Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 + +Adds support for Namecoin .bit urls to NIP-05 +- Adds choice of ElectrumX server to resolve namecoins. + +Adds basic support for Chess with Jester protocol + +Adds NIP-46 support to Quartz and Amethyst Desktop + +Adds a Broadcasting feedback pop-up in the Complete UI mode + +Adds support for rendering Zap events when quoted inside of posts. + +Removes support for NIP-96 and updates Blossom recommendations + +Adds support to upload Documents to all new post screens. + +Redesigns and reorganizes Setting pages +- Consolidate drawer settings into a single Settings hub screen +- Redesigns Zap Amount and NWC setup screens +- Redesigns Custom zap amount screens +- Add reactions row settings (enable/disable, order, show/hide counters) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 +- Tapping on Zap without any pre-configured amount opens the custom dialog + +URL/URI parser rewrite in Kotlin multiplatform (KMP) +- Fixes characters attached to URLs or nostr URLs without a space +- Massively increases parsing performance +- Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd +- Adds a parser for blossom: uris + +Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx + +Fixes bug on Show More calculations for very long texts without spaces + +Relay Management: +- Adds relay search tooltip when adding relays +- Adds the list of keys using each relay to the relay information +- Adds active subscriptions and outbox event in the queue to relay information +- Adds a complete list of event kind names to the subscription card to relay information +- Tracks and displays connection success rate on relay settings + +Search fixes +- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content. +- Fixes the need to start user searches with @ in user fields +- Fixes the stability of the search feed when the user navigates away and back. +- Replaces about me for NIP-05 in the user search results +- Adds relay URL search to the search page +- Forces returning one user when searching by nip-05 +- Removes outdated versions of addressables from the search results + +Profiles: +- Adds a profile picture upload button when the user has no picture +- Groups received zap amounts by sending the user in the profile tab +- Increases the limit of Zap downloads for profiles to 1000 +- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx +- Migrates profile galleries to display a thumbnail for videos +- Fixes profile galleries' aspect ratios + +Bulk Follow onboarding +- Adds screens to search for a user and to copy his/her follow list + +Voice message support by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Adds voice anonymization +- Change from "hold to record" to "click to start, click to stop" +- Display kind 1 voice replies as an audio waveform +- Increases max voice record duration to 600 seconds +- Switches the public message event to use quoted posts for replies + +Fixes: +- Fixes "forked from" label rendering over the name +- Avoids crashing when the `k` tag cannot be parsed to a number +- Only use Voice Reply events when replying to voice notes. Others just receive a URL. +- Fixes the lack of update in the follow count on the UserProfile page +- Fixes out of memory when downloading large videos +- Fixes Jackson deserialization for empty Filters and add regression test by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 +- Fixes NullPointerException when the filter contains tags +- Fixes download cancellations when screen components disappear +- Migrates to use "title" instead of "name" tags for NIP-51 lists +- Adds a longer crop for npubs so that we can see vanity keys better +- Fixes the need to have tags and kinds for inbox.nostr.wine to work +- Blocks the size of Relay Auth Status arrays from growing forever with auth messages +- Fixes crash when getting OpenGraph tags of invalid URLs +- Fixes NIP-44 key mutation in NIP-46 connect +- Location permission watcher moved outside screens to avoid recreation + +AI: +- Add SKILL.md for AI agent customization + +Defaults: +- Switches wss://nostr.band to wss://antiprimal.net, wss://relay.ditto.pub on app defaults +- Adds wss://nostr.wine, wss://news.utxo.one as favorite relay feeds +- Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays +- Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers + +Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k +- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge. +- Provide implementation for LargeCache, using a CacheMap. +- Provide implementation for fastFindURLs() +- Provide implementation for makeAbsoluteIfRelativeUrl() in ServerInfoParser.ios.kt +- Provide implementation for UrlEncoder. +- Provide implementation for UnicodeNormalizer. +- Provide implementation for GZip compression/decompression. Some small fixes in URLs.ios.kt +- Provide implementation for AESCBC. +- Provide implementation for AESGCM. +- Provide implementation for DigestInstance. +- Adds a reqUntilEoseAsFlow extension to the Nostr Client + +Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c +- Adds NIP-46 Bunker Login +- Adds Support for Chess +- Adds Thread Screens +- Adds encrypted DMs (NIP-04/NIP-17) +- Adds proper empty states with EOSE tracking +- Adds multi-column deck layout +- Clear stored credentials on logout +- Adds bunker heartbeat indicator +- Adds QR-based signer pairing +- Migrates lifecycle-viewmodel KMP dependencies to KMP/Commons +- Migrates drawReplyLevel modifier to KMP/Commons +- Migrates ThreadFilter to KMP/Commons +- Migrates Card interface and CardFeedState to KMP/Commons +- Migrates Channels (public chats, ephemeral channels, and live streams) Account modules to KMP/Commons +- Migrates private chatroom models to KMP/Commons +- Migrates reports states to KMP/Commons +- Migrates Emoji State to KMP/Commons +- Migrates lud06 to lud16 mapping to KMP/Quartz +- Migrates the new LocalCache observables to KMP/Commons +- Migrates rich text parser from JVM to KMP/Commons + +Code Quality +- Migrates to AGP 9.0 +- Removes the in-app memory counter methods +- Refactors the old NIP-05 code on Quartz +- Migrates contact list management to addressable notes +- Creates new observable flows for LocalCache. +- Moves metadata methods from User to UserCache objects +- Separate Addressable vs Replaceable event class bases +- Avoid dependency on AccountSettings for NwcSignerState +- Finishes the transition to EventHint objects for building events. +- Lots of code review fixes by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Large accessibility review by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Moves Top Nav Filter markers from Strings to full objects. +- Removes support for feed definitions +- AccountState refactoring + +Updated translations: +- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp +- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz +- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm +- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 +- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw +- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t +- Chinese by hypnotichemionus4 + # [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 diff --git a/README.md b/README.md index 28219bf87..d1d9dbe7b 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Request to Vanish (NIP-62) - [x] Chess (NIP-64) - [x] Relay List Metadata (NIP-65) -- [ ] Relay Discovery and Monitoring (NIP-66) +- [x] Relay Discovery and Monitoring (NIP-66) - [x] Picture-first feeds (NIP-68) - [ ] Peer-to-peer Orders (NIP-69) - [o] Protected Events (NIP-70) @@ -136,7 +136,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Web Bookmarks - [x] Blossom (NIP-B7) - [ ] Nostr BLE Communications Protocol (NIP-BE) -- [ ] Code Snippets (NIP-C0) +- [x] Code Snippets (NIP-C0) - [ ] NIP-29 Chats (NIP-C7) - [ ] MLS Protocol (NIP-EE) - [x] Audio Tracks (zapstr.live) (kind:31337) @@ -151,6 +151,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Medical Data (NIP-xx/Draft) - [x] Embed events (NIP-xx/Draft) - [x] Edit Short Notes (NIP-xx/Draft) +- [x] NIP Events (NIP-xx/Draft) - [ ] Relationship Status (NIP-xx/Draft) - [ ] Signed Filters (NIP-xx/Draft) - [ ] Key Migration (NIP-xx/Draft) @@ -158,7 +159,6 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Video Capture in the app - [ ] Local Database - [ ] Workspaces -- [ ] Infinity Scroll ## Privacy and Information Permanence diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 3aebe1c1c..a5f447e65 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -80,7 +80,7 @@ class ImageUploadTesting { .addInterceptor(DefaultContentTypeInterceptor("Amethyst/${BuildConfig.VERSION_NAME}")) .build() - private suspend fun getBitmap(): ByteArray { + private fun getBitmap(): ByteArray { val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888) for (x in 0 until bitmap.width) { for (y in 0 until bitmap.height) { @@ -127,7 +127,7 @@ class ImageUploadTesting { // assertEquals(server.baseUrl, "${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png")) val imageData: ByteArray = - ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes + ImageDownloader().waitAndGetImage(result.url!!) { client }?.bytes ?: run { fail("${server.name}: Should not be null") return @@ -142,8 +142,7 @@ class ImageUploadTesting { ServerInfoRetriever() .loadInfo( server.baseUrl, - { client }, - ) + ) { client } val payload = getBitmap() val inputStream = payload.inputStream() @@ -172,7 +171,7 @@ class ImageUploadTesting { Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http")) val imageData: ByteArray = - ImageDownloader().waitAndGetImage(url, { client })?.bytes + ImageDownloader().waitAndGetImage(url) { client }?.bytes ?: run { fail("${server.name}: Should not be null") return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index c05fa392c..5885c1e22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -105,6 +106,11 @@ class AppModules( TorSharedPreferences(appContext, applicationIOScope) } + // Namecoin ElectrumX server preferences (global, like Tor settings) + val namecoinPrefs by lazy { + NamecoinSharedPreferences(appContext, applicationIOScope) + } + // App services that should be run as soon as there are subscribers to their flows val locationManager = LocationState(appContext, applicationIOScope) val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -154,11 +160,13 @@ class AppModules( socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, ), serverListProvider = { - if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { - TOR_ELECTRUMX_SERVERS - } else { - DEFAULT_ELECTRUMX_SERVERS - } + // User-configured custom servers take priority + namecoinPrefs.customServersOrNull + ?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + TOR_ELECTRUMX_SERVERS + } else { + DEFAULT_ELECTRUMX_SERVERS + } }, ) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) @@ -226,7 +234,7 @@ class AppModules( val relayStats = RelayStats(client) // Logs debug messages when needed - val detailedLogger = if (isDebug) RelayLogger(client, false, false) else null + val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 50b6ad326..713fe5978 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -53,8 +53,8 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -102,7 +102,7 @@ private object PrefKeys { const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList" const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList" - const val LATEST_FAVORITE_RELAY_LIST = "latestFavoriteRelayList" + const val LATEST_RELAY_FEEDS_LIST = "latestRelayFeedsList" const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList" const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList" const val LATEST_MUTE_LIST = "latestMuteList" @@ -336,7 +336,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList) putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList) putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList) - putOrRemove(PrefKeys.LATEST_FAVORITE_RELAY_LIST, settings.backupIndexRelayList) + putOrRemove(PrefKeys.LATEST_RELAY_FEEDS_LIST, settings.backupRelayFeedsList) putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList) putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList) @@ -471,7 +471,7 @@ object LocalPreferences { val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) val latestIndexRelayList = parseEventOrNull(PrefKeys.LATEST_INDEX_RELAY_LIST) - val latestFavoriteRelayList = parseEventOrNull(PrefKeys.LATEST_FAVORITE_RELAY_LIST) + val latestRelayFeedsList = parseEventOrNull(PrefKeys.LATEST_RELAY_FEEDS_LIST) val latestBlockedRelayList = parseEventOrNull(PrefKeys.LATEST_BLOCKED_RELAY_LIST) val latestTrustedRelayList = parseEventOrNull(PrefKeys.LATEST_TRUSTED_RELAY_LIST) val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) @@ -517,7 +517,7 @@ object LocalPreferences { backupDMRelayList = latestDmRelayList, backupSearchRelayList = latestSearchRelayList, backupIndexRelayList = latestIndexRelayList, - backupFavoriteRelayList = latestFavoriteRelayList, + backupRelayFeedsList = latestRelayFeedsList, backupBlockedRelayList = latestBlockedRelayList, backupTrustedRelayList = latestTrustedRelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3b65d75b5..055cb6ae2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayStat import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState import com.vitorpamplona.amethyst.model.nip02FollowLists.DeclaredFollowsPerOutboxRelay +import com.vitorpamplona.amethyst.model.nip02FollowLists.DeclaredFollowsPerUsingRelay import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListReusedOutboxOrProxyRelays import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay @@ -60,8 +61,6 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache -import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -76,6 +75,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecrypti import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedListState +import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache @@ -277,8 +278,8 @@ class Account( val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer) val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings) - val favoriteRelayListDecryptionCache = FavoriteRelayListDecryptionCache(signer) - val favoriteRelayList = FavoriteRelayListState(signer, cache, favoriteRelayListDecryptionCache, scope, settings) + val relayFeedsListDecryptionCache = RelayFeedsListDecryptionCache(signer) + val relayFeedsList = RelayFeedListState(signer, cache, relayFeedsListDecryptionCache, scope, settings) val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer) val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) @@ -340,7 +341,8 @@ class Account( val defaultGlobalRelays = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, scope) // keeps a cache of the declared outbox relays for each author - val declaredFollowsPerRelay = DeclaredFollowsPerOutboxRelay(kind3FollowList, cache, scope).flow + val declaredFollowsPerOutboxRelay = DeclaredFollowsPerOutboxRelay(kind3FollowList, cache, scope).flow + val declaredFollowsPerUsingRelay = DeclaredFollowsPerUsingRelay(kind3FollowList, cache, scope).flow // keeps a cache of the outbox relays for each author val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow @@ -933,6 +935,8 @@ class Account( fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic) + suspend fun follow(users: List) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(users)) + suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user)) @@ -1240,7 +1244,7 @@ class Account( val event = signer.sign(template) cache.justConsumeMyOwnEvent(event) val relays = relayList(event) - if (relays != null && relays.isNotEmpty()) { + if (!relays.isNullOrEmpty()) { client.send(event, relays.toSet()) } else { client.send(event, computeRelayListToBroadcast(event)) @@ -1898,7 +1902,7 @@ class Account( fun getRelevantReports(note: Note): Set { val innerReports = if (note.event is RepostEvent || note.event is GenericRepostEvent) { - note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() + note.replyTo?.flatMap { getRelevantReports(it) } ?: emptyList() } else { emptyList() } @@ -1924,11 +1928,11 @@ class Account( suspend fun saveTrustedRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) - suspend fun saveFavoriteRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays)) + suspend fun saveRelayFeedsList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(relayFeedsList.saveRelayList(trustedRelays)) - suspend fun followFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.addRelay(url)) + suspend fun followRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.addRelay(url)) - suspend fun unfollowFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.removeRelay(url)) + suspend fun unfollowRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.removeRelay(url)) suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 94c178cd6..57538f5a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -46,8 +46,8 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission @@ -82,6 +82,8 @@ val DefaultNIP65List = AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH), ) +val DefaultGlobalRelays = listOf(Constants.wine, Constants.news) + val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos) val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto) @@ -173,7 +175,7 @@ class AccountSettings( var backupNIP65RelayList: AdvertisedRelayListEvent? = null, var backupSearchRelayList: SearchRelayListEvent? = null, var backupIndexRelayList: IndexerRelayListEvent? = null, - var backupFavoriteRelayList: FavoriteRelayListEvent? = null, + var backupRelayFeedsList: RelayFeedsListEvent? = null, var backupBlockedRelayList: BlockedRelayListEvent? = null, var backupTrustedRelayList: TrustedRelayListEvent? = null, var backupMuteList: MuteListEvent? = null, @@ -186,8 +188,8 @@ class AccountSettings( var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), - val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), - val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), + val hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), + val pendingAttestations: MutableStateFlow> = MutableStateFlow(mapOf()), var backupNipA3PaymentTargets: PaymentTargetsEvent? = null, ) : EphemeralChatRepository, PublicChatListRepository { @@ -425,12 +427,12 @@ class AccountSettings( } } - fun updateFavoriteRelayList(newFavoriteRelayList: FavoriteRelayListEvent?) { - if (newFavoriteRelayList == null || newFavoriteRelayList.tags.isEmpty()) return + fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) { + if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return // Events might be different objects, we have to compare their ids. - if (backupFavoriteRelayList?.id != newFavoriteRelayList.id) { - backupFavoriteRelayList = newFavoriteRelayList + if (backupRelayFeedsList?.id != newRelayFeedList.id) { + backupRelayFeedsList = newRelayFeedList saveAccountSettings() } } @@ -612,7 +614,7 @@ class AccountSettings( route: String, timestampInSecs: Long, ): MutableStateFlow = - MutableStateFlow(timestampInSecs).also { newFlow -> + MutableStateFlow(timestampInSecs).also { newFlow -> lastReadPerRoute.update { it + Pair(route, newFlow) } saveAccountSettings() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 2d21b2b66..75c9e3aa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -170,7 +170,7 @@ class AntiSpamFilter { } } - val flowSpam = MutableStateFlow(AntiSpamState(this)) + val flowSpam = MutableStateFlow(AntiSpamState(this)) } class AntiSpamState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt index 417b4d7b8..754080858 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt @@ -42,6 +42,8 @@ object Constants { val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com") val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com") + val news = RelayUrlNormalizer.normalize("wss://news.utxo.one") + val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es") val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social") val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt index c9a61d5cc..bc7c6cce3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCache.kt @@ -25,14 +25,13 @@ import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentSkipListMap import java.util.function.BiConsumer -class LargeSoftCache : CacheOperations { - protected val cache = ConcurrentSkipListMap>() +class LargeSoftCache : CacheOperations { + private val cache = ConcurrentSkipListMap>() fun keys() = cache.keys fun get(key: K): V? { - val softRef = cache.get(key) - if (softRef == null) return null + val softRef = cache.get(key) ?: return null val value = softRef.get() return if (value != null) { @@ -99,7 +98,7 @@ class LargeSoftCache : CacheOperations { // another thread may put in between cache.remove(key, softRef) val newObject = builder(key) - return cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject + cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject } } } @@ -135,7 +134,7 @@ class LargeSoftCache : CacheOperations { .forEach(BiConsumerWrapper(this, consumer)) } - class BiConsumerWrapper( + class BiConsumerWrapper( val cache: LargeSoftCache, val inner: BiConsumer, ) : BiConsumer> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 0f637f3ed..787484503 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -148,9 +148,9 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -204,6 +204,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -1213,7 +1214,7 @@ object LocalCache : ILocalCache, ICacheProvider { ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( - event: FavoriteRelayListEvent, + event: RelayFeedsListEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) @@ -2276,20 +2277,32 @@ object LocalCache : ILocalCache, ICacheProvider { } } + val dualCase = + listOf( + DualCase(username.lowercase(), username.uppercase()), + ) + val finds = users.filter { _, user: User -> - ( - (user.metadataOrNull()?.anyNameStartsWith(username) == true) || - user.pubkeyHex.startsWith(username, true) || + val metadata = user.metadataOrNull() + if (metadata == null) { + user.pubkeyHex.startsWith(username, true) || user.pubkeyNpub().startsWith(username, true) - ) && - (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) + } else { + ( + metadata.anyNameOrAddressContains(dualCase) || + user.pubkeyHex.startsWith(username, true) || + user.pubkeyNpub().startsWith(username, true) + ) && + (forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) + } } return finds.sortedWith( compareBy( { forAccount?.isFollowing(it) == false }, - { !it.toBestDisplayName().startsWith(username, ignoreCase = true) }, + { it.metadataOrNull()?.anyNameStartsWith(dualCase) == false }, + { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false }, { it.toBestDisplayName().lowercase() }, { it.pubkeyHex }, ), @@ -2770,10 +2783,9 @@ object LocalCache : ILocalCache, ICacheProvider { val childrenToBeRemoved = mutableListOf() val toBeRemoved = - account.hiddenUsers.flow.value.hiddenUsers - .map { userHex -> - (notes.filter { _, it -> it.event?.pubKey == userHex } + addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() - }.flatten() + account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex -> + (notes.filter { _, it -> it.event?.pubKey == userHex } + addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() + } toBeRemoved.forEach { removeFromCache(it) @@ -3084,7 +3096,7 @@ object LocalCache : ILocalCache, ICacheProvider { is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) is ChessGameEvent -> consume(event, relay, wasVerified) - is FavoriteRelayListEvent -> consume(event, relay, wasVerified) + is RelayFeedsListEvent -> consume(event, relay, wasVerified) is JesterEvent -> consume(event, relay, wasVerified) is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/DeclaredFollowsPerUsingRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/DeclaredFollowsPerUsingRelay.kt new file mode 100644 index 000000000..cc99d7801 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/DeclaredFollowsPerUsingRelay.kt @@ -0,0 +1,67 @@ +/* + * 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.nip02FollowLists + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.UsingRelayUnwrapper +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +/** + * Computes a list of all declared relays for each user in the follow list. + */ +class DeclaredFollowsPerUsingRelay( + kind3Follows: Kind3FollowListState, + val cache: LocalCache, + scope: CoroutineScope, +) { + val calculator = UsingRelayUnwrapper() + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow>> = + kind3Follows.flow + .transformLatest { + emitAll( + calculator.toUsersPerRelayFlow(it.authors, cache) { it }, + ) + }.onStart { + emit( + calculator.usersPerRelaySnapshot(kind3Follows.flow.value.authors, cache) { it }, + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt index de3fba5d6..35fd21cd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt @@ -113,6 +113,25 @@ class Kind3FollowListState( ) } + suspend fun follow(users: List): ContactListEvent { + val contactList = getFollowListEvent() + + val contacts = + users.map { + ContactTag(it.pubkeyHex, it.bestRelayHint(), null) + } + + return if (contactList != null) { + ContactListEvent.followUsers(contactList, contacts, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = contacts, + relayUse = emptyMap(), + signer = signer, + ) + } + } + suspend fun follow(user: User): ContactListEvent { val contactList = getFollowListEvent() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt index b0c01c92a..d075edd0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt @@ -34,9 +34,9 @@ class TorAwareOkHttpOtsResolverBuilder( ) : OtsResolverBuilder { fun getAPI(usingTor: Boolean) = if (usingTor) { - OkHttpBitcoinExplorer.Companion.MEMPOOL_API_URL + OkHttpBitcoinExplorer.MEMPOOL_API_URL } else { - OkHttpBitcoinExplorer.Companion.BLOCKSTREAM_API_URL + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL } override fun build(): OtsResolver = @@ -44,9 +44,9 @@ class TorAwareOkHttpOtsResolverBuilder( explorer = OkHttpBitcoinExplorer( baseUrl = { - getAPI(usingTor = isTorActive(OkHttpBitcoinExplorer.Companion.MEMPOOL_API_URL)) + getAPI(usingTor = isTorActive(OkHttpBitcoinExplorer.MEMPOOL_API_URL)) }, - client = okHttpClient(OkHttpBitcoinExplorer.Companion.MEMPOOL_API_URL), + client = okHttpClient(OkHttpBitcoinExplorer.MEMPOOL_API_URL), cache = cache, ), calendar = OkHttpCalendar(okHttpClient), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index ca15f42c4..9a438a880 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -62,7 +62,7 @@ class BookmarkListState( fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent - suspend fun publicBookmarks(note: Note): List { + fun publicBookmarks(note: Note): List { val noteEvent = note.event as? BookmarkListEvent return noteEvent?.publicBookmarks() ?: emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt index c39699277..8f45d8534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -47,7 +47,7 @@ class HiddenUsersState( ) { var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) - suspend fun assembleLiveHiddenUsers( + fun assembleLiveHiddenUsers( blockList: List, muteList: List, transientHiddenUsers: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt index fc82eb262..8a16f79bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt @@ -34,9 +34,9 @@ data class LabeledBookmarkList( val privateBookmarks: Set = emptySet(), val publicBookmarks: Set = emptySet(), ) { - val privatePostBookmarks = privateBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark } - val publicPostBookmarks = publicBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark } + val privatePostBookmarks = privateBookmarks.filterIsInstance() + val publicPostBookmarks = publicBookmarks.filterIsInstance() - val privateArticleBookmarks = privateBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark } - val publicArticleBookmarks = publicBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark } + val privateArticleBookmarks = privateBookmarks.filterIsInstance() + val publicArticleBookmarks = publicBookmarks.filterIsInstance() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index dc178a8e5..3787caac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -68,7 +68,10 @@ class PeopleListsState( ) { val user = cache.getOrCreateUser(signer.pubKey) - fun existingPeopleListNotes() = cache.addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) + fun existingPeopleListNotes() = + cache.addressables + .filter(PeopleListEvent.KIND, user.pubkeyHex) + .filter { it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG || it.event != null } val peopleListVersions = MutableStateFlow(0) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt similarity index 52% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt index ac7cfe9bd..1ba8389e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt @@ -18,7 +18,7 @@ * 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.favoriteRelays +package com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -40,32 +40,32 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class FavoriteRelayListState( +class RelayFeedListState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: FavoriteRelayListDecryptionCache, + val decryptionCache: RelayFeedsListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val favoriteListNote = cache.getOrCreateAddressableNote(getFavoriteRelayListAddress()) + val relayFeedListNote = cache.getOrCreateAddressableNote(getRelayFeedsListAddress()) - fun getFavoriteRelayListAddress() = FavoriteRelayListEvent.createAddress(signer.pubKey) + fun getRelayFeedsListAddress() = RelayFeedsListEvent.createAddress(signer.pubKey) - fun getFavoriteRelayListFlow(): StateFlow = favoriteListNote.flow().metadata.stateFlow + fun getRelayFeedsListFlow(): StateFlow = relayFeedListNote.flow().metadata.stateFlow - fun getFavoriteRelayList(): FavoriteRelayListEvent? = favoriteListNote.event as? FavoriteRelayListEvent + fun getRelayFeedsList(): RelayFeedsListEvent? = relayFeedListNote.event as? RelayFeedsListEvent - fun favoriteListEvent(note: Note) = note.event as? FavoriteRelayListEvent ?: settings.backupFavoriteRelayList + fun relayFeedsListEvent(note: Note) = note.event as? RelayFeedsListEvent ?: settings.backupRelayFeedsList - suspend fun normalizeFavoriteRelayListWithBackup(note: Note): Set = favoriteListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet() + suspend fun normalizeRelayFeedsListWithBackup(note: Note): Set = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet() - suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set = favoriteListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + suspend fun normalizeRelayFeedsListWithBackupNoDefaults(note: Note): Set = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() val flow = - getFavoriteRelayListFlow() - .map { normalizeFavoriteRelayListWithBackup(it.note) } - .onStart { emit(normalizeFavoriteRelayListWithBackup(favoriteListNote)) } + getRelayFeedsListFlow() + .map { normalizeRelayFeedsListWithBackup(it.note) } + .onStart { emit(normalizeRelayFeedsListWithBackup(relayFeedListNote)) } .flowOn(Dispatchers.IO) .stateIn( scope, @@ -74,9 +74,9 @@ class FavoriteRelayListState( ) val flowNoDefaults = - getFavoriteRelayListFlow() - .map { normalizeFavoriteRelayListWithBackupNoDefaults(it.note) } - .onStart { emit(normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote)) } + getRelayFeedsListFlow() + .map { normalizeRelayFeedsListWithBackupNoDefaults(it.note) } + .onStart { emit(normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote)) } .flowOn(Dispatchers.IO) .stateIn( scope, @@ -84,49 +84,49 @@ class FavoriteRelayListState( emptySet(), ) - suspend fun addRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent { - val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList() + suspend fun addRelay(relay: NormalizedRelayUrl): RelayFeedsListEvent { + val current = normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote).toMutableList() if (relay !in current) current.add(relay) return saveRelayList(current) } - suspend fun removeRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent? { - val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList() + suspend fun removeRelay(relay: NormalizedRelayUrl): RelayFeedsListEvent? { + val current = normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote).toMutableList() if (relay !in current) return null current.remove(relay) return saveRelayList(current) } - suspend fun saveRelayList(favoriteRelays: List): FavoriteRelayListEvent { - val relayListForFavorite = getFavoriteRelayList() + suspend fun saveRelayList(relayFeeds: List): RelayFeedsListEvent { + val relayFeedsList = getRelayFeedsList() - return if (relayListForFavorite != null && relayListForFavorite.tags.isNotEmpty()) { - FavoriteRelayListEvent.updateRelayList( - earlierVersion = relayListForFavorite, - relays = favoriteRelays, + return if (relayFeedsList != null && relayFeedsList.tags.isNotEmpty()) { + RelayFeedsListEvent.updateRelayList( + earlierVersion = relayFeedsList, + relays = relayFeeds, signer = signer, ) } else { - FavoriteRelayListEvent.create( - relays = favoriteRelays, + RelayFeedsListEvent.create( + relays = relayFeeds, signer = signer, ) } } init { - settings.backupFavoriteRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved favorite relay list ${it.toJson()}") + settings.backupRelayFeedsList?.let { + Log.d("AccountRegisterObservers", "Loading saved relay feeds list ${it.toJson()}") @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } } scope.launch(Dispatchers.IO) { - Log.d("AccountRegisterObservers", "Favorite Relay List Collector Start") - getFavoriteRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Favorite Relay List for ${signer.pubKey}") - (it.note.event as? FavoriteRelayListEvent)?.let { - settings.updateFavoriteRelayList(it) + Log.d("AccountRegisterObservers", "Relay feeds list Collector Start") + getRelayFeedsListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Relay feeds list for ${signer.pubKey}") + (it.note.event as? RelayFeedsListEvent)?.let { + settings.updateRelayFeedList(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedsListDecryptionCache.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedsListDecryptionCache.kt index dee688435..9742807c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedsListDecryptionCache.kt @@ -18,12 +18,12 @@ * 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.favoriteRelays +package com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -class FavoriteRelayListDecryptionCache( +class RelayFeedsListDecryptionCache( signer: NostrSigner, -) : GenericRelayListCache(signer) +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index e382bdfdd..1f823adbc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -51,7 +51,7 @@ class SearchRelayListState( // Creates a long-term reference for this note so that the GC doesn't collect the note it self val searchListNote = cache.getOrCreateAddressableNote(getSearchRelayListAddress()) - fun getSearchRelayListAddress() = SearchRelayListEvent.Companion.createAddress(signer.pubKey) + fun getSearchRelayListAddress() = SearchRelayListEvent.createAddress(signer.pubKey) fun getSearchRelayListFlow(): StateFlow = searchListNote.flow().metadata.stateFlow @@ -70,7 +70,7 @@ class SearchRelayListState( .flowOn(Dispatchers.IO) .stateIn( scope, - SharingStarted.Companion.Eagerly, + SharingStarted.Eagerly, emptySet(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt index 11eef7564..995c41cd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/trustedRelays/TrustedRelayListState.kt @@ -68,7 +68,7 @@ class TrustedRelayListState( .flowOn(Dispatchers.IO) .stateIn( scope, - SharingStarted.Companion.Eagerly, + SharingStarted.Eagerly, emptySet(), ) @@ -76,13 +76,13 @@ class TrustedRelayListState( val relayListForTrusted = getTrustedRelayList() return if (relayListForTrusted != null && relayListForTrusted.tags.isNotEmpty()) { - TrustedRelayListEvent.Companion.updateRelayList( + TrustedRelayListEvent.updateRelayList( earlierVersion = relayListForTrusted, relays = trustedRelays, signer = signer, ) } else { - TrustedRelayListEvent.Companion.create( + TrustedRelayListEvent.create( relays = trustedRelays, signer = signer, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index 735868c78..83aad4732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -79,7 +79,7 @@ class BlossomServerListState( emptyList(), ) - fun mergeServerList(blossom: List?): List = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() + fun mergeServerList(blossom: List?): List = blossom?.map { ServerName(host(it), it, ServerType.Blossom) }?.ifEmpty { DEFAULT_MEDIA_SERVERS } ?: DEFAULT_MEDIA_SERVERS val hostNameFlow: StateFlow> = flow @@ -120,5 +120,5 @@ class BlossomServerListState( suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ): BlossomAuthorizationEvent? = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt index f66037979..6c6611192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt @@ -29,13 +29,13 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import java.io.IOException -suspend fun DataStore.getProperty( +fun DataStore.getProperty( key: Preferences.Key, parser: (String) -> T, serializer: (T) -> String, scope: CoroutineScope, ): UpdatablePropertyFlow = - UpdatablePropertyFlow( + UpdatablePropertyFlow( flow = data .catch { e -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt index 6ec67ae1a..02498472a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt @@ -67,12 +67,12 @@ class EncryptedDataStore( ?.get(key) ?.let { decrypt(it) } - suspend fun getProperty( + fun getProperty( key: Preferences.Key, parser: (String) -> T, serializer: (T) -> String, ): UpdatablePropertyFlow = - UpdatablePropertyFlow( + UpdatablePropertyFlow( flow = store.data .catch { e -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt new file mode 100644 index 000000000..600cecaa9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -0,0 +1,143 @@ +/* + * 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.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.utils.Log +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.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.coroutines.cancellation.CancellationException + +/** + * Persistent storage for [NamecoinSettings], following the same pattern as + * [TorSharedPreferences]. + * + * Uses the app-wide [sharedPreferencesDataStore] so Namecoin resolution + * settings (like Tor settings) are global — not per-account. + * + * The current settings are available synchronously via [settings] (a + * [StateFlow]) and can be read in non-suspend contexts (e.g. in a + * `serverListProvider` lambda). + */ +@Stable +class NamecoinSharedPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + private val json = Json { ignoreUnknownKeys = true } + + companion object { + val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled") + val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers") + } + + /** + * Current settings, loaded synchronously at init to avoid races. + */ + private val _settings = + MutableStateFlow( + runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT }, + ) + val settings: StateFlow = _settings + + /** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */ + val current: NamecoinSettings get() = _settings.value + + /** + * Parsed [ElectrumxServer] list from current custom settings, or `null` + * if the user hasn't configured any (meaning "use defaults"). + */ + val customServersOrNull: List? + get() = current.toElectrumxServers() + + // ── Mutators ─────────────────────────────────────────────────────── + + suspend fun setEnabled(enabled: Boolean) { + val updated = current.copy(enabled = enabled) + persist(updated) + } + + suspend fun addServer(server: String) { + if (server.isBlank() || server in current.customServers) return + val updated = current.copy(customServers = current.customServers + server) + persist(updated) + } + + suspend fun removeServer(server: String) { + val updated = current.copy(customServers = current.customServers - server) + persist(updated) + } + + suspend fun reset() { + persist(NamecoinSettings.DEFAULT) + } + + // ── Internal ─────────────────────────────────────────────────────── + + private suspend fun persist(settings: NamecoinSettings) { + _settings.value = settings + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY_ENABLED] = settings.enabled + prefs[KEY_CUSTOM_SERVERS] = + json.encodeToString( + settings.customServers.filter { it.isNotBlank() }, + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error writing DataStore: ${e.message}") + } + } + + private suspend fun loadFromDisk(): NamecoinSettings? = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val enabled = prefs[KEY_ENABLED] ?: true + val serversJson = prefs[KEY_CUSTOM_SERVERS] + val servers = + if (serversJson != null) { + try { + json.decodeFromString>(serversJson) + } catch (_: Exception) { + emptyList() + } + } else { + emptyList() + } + NamecoinSettings(enabled = enabled, customServers = servers) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("NamecoinPrefs", "Error reading DataStore: ${e.message}") + null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt index dba3ca53d..f3d7ae385 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -60,8 +60,8 @@ class MergedFollowListsState( val geotags: Set = emptySet(), val communities: Set = emptySet(), ) { - val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } - val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } } fun mergeLists( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt index 49bf8371c..708731655 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt @@ -48,7 +48,7 @@ class OutboxLoaderState( }.flowOn(Dispatchers.IO) .stateIn( scope, - SharingStarted.Companion.Eagerly, + SharingStarted.Eagerly, UnknownTopNavPerRelayFilterSet, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/UsingRelayUnwrapper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/UsingRelayUnwrapper.kt new file mode 100644 index 000000000..e58fac34b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/UsingRelayUnwrapper.kt @@ -0,0 +1,115 @@ +/* + * 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.topNavFeeds + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine + +class UsingRelayUnwrapper { + fun usersPerRelay(relayListNotes: Array): Map> = + mapOfSet { + relayListNotes.forEach { outboxNote -> + val note = outboxNote.note + + val authorHex = + if (note is AddressableNote) { + note.address.pubKeyHex + } else { + note.author?.pubkeyHex + } + + if (authorHex != null) { + val relays = + when (val noteEvent = outboxNote.note.event) { + is AdvertisedRelayListEvent -> noteEvent.relaysNorm() + is ChatMessageRelayListEvent -> noteEvent.relays() + else -> emptySet() + } + + relays.forEach { + add(it, authorHex) + } + relays.forEach { + add(it, authorHex) + } + } + } + } + + fun usersPerRelaySnapshot( + users: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val nip65RelayNotes = + users + .map { pubkeyHex -> + cache + .getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + .flow() + .metadata.stateFlow.value + }.toTypedArray() + + val nip17RelayNotes = + users + .map { pubkeyHex -> + cache + .getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex)) + .flow() + .metadata.stateFlow.value + }.toTypedArray() + + return transformation(usersPerRelay(nip65RelayNotes + nip17RelayNotes)) + } + + fun toUsersPerRelayFlow( + users: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val relayNoteFlows = + users.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + users.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + return if (relayNoteFlows.isEmpty()) { + MutableStateFlow(transformation(emptyMap())) + } else { + combine(relayNoteFlows) { relayListNotes -> + transformation(usersPerRelay(relayListNotes)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt index a607a0d42..6386b1d4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -52,8 +52,8 @@ class AllFollowsByOutboxTopNavFilter( val defaultRelays: StateFlow>, val blockedRelays: StateFlow>, ) : IFeedTopNavFilter { - val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } - val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.toScope(it) } override fun matchAuthor(pubkey: HexKey): Boolean = authors == null || pubkey in authors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt index edd06abd5..bed71c9b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt @@ -47,8 +47,8 @@ class AllFollowsByProxyTopNavFilter( val communities: Set? = null, val proxyRelays: Set, ) : IFeedTopNavFilter { - val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } - val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.toScope(it) } override fun matchAuthor(pubkey: HexKey): Boolean = authors == null || pubkey in authors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt index ff670117e..443bfd5c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt @@ -35,6 +35,6 @@ class AllFollowsTopNavPerRelayFilter( val geotags: Set? = null, val communities: Set? = null, ) : IFeedTopNavPerRelayFilter { - val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.toScope(it) } - val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.toScope(it) } + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.toScope(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt index 7f0284b1c..24b39bee6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt @@ -37,7 +37,7 @@ class LocationTopNavFilter( val geotags: Set, val relayList: Set, ) : IFeedTopNavFilter { - val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } override fun matchAuthor(pubkey: HexKey): Boolean = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt index 4792a92b2..bd1cd0743 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt @@ -28,5 +28,5 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId class LocationTopNavPerRelayFilter( val geotags: Set, ) : IFeedTopNavPerRelayFilter { - val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt index a32aabae3..fbd5492b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt @@ -62,7 +62,7 @@ class NoteFeedFlow( ): IFeedTopNavFilter = when (noteEvent) { is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) } else { AuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) @@ -109,7 +109,7 @@ class NoteFeedFlow( ) { when (noteEvent) { is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { emit(MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) } else { emit(AuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) @@ -161,7 +161,7 @@ class NoteFeedFlow( ): IFeedTopNavFilter = when (noteEvent) { is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) } else { AuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) @@ -208,7 +208,7 @@ class NoteFeedFlow( ) { when (noteEvent) { is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { emit(MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) } else { emit(AuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) @@ -272,7 +272,7 @@ class NoteFeedFlow( override fun startValue(): IFeedTopNavFilter { val noteEvent = metadataFlow.value?.note?.event return if (noteEvent == null) { - return AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) + AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) } else { if (proxyRelays.value.isEmpty()) { processByOutbox(noteEvent, outboxRelays.value) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index 9337103dd..70c802ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -24,6 +24,7 @@ import android.util.LruCache import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.commons.richtext.UrlParser object CachedRichTextParser { private val richTextCache = LruCache(50) @@ -77,7 +78,8 @@ object CachedUrlParser { return if (cached != null) { cached } else { - val newUrls = RichTextParser().parseValidUrls(content).toList() + val urlSet = UrlParser().parseValidUrls(content) + val newUrls = urlSet.withScheme.filter { it.startsWith("http") } + urlSet.withoutScheme.map { "http://$it" } + urlSet.bech32s.map { "http://$it" } parsedUrlsCache.put(key, newUrls) newUrls } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index d12b1ea08..6ea4f28bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -83,7 +83,7 @@ class MeltProcessor { ) } - suspend fun melt( + fun melt( token: CashuToken, lud16: String, okHttpClient: (String) -> OkHttpClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index 925209294..c2ebbb38a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -32,7 +32,7 @@ class MemoryTrimmingService( ) { var isTrimmingMemoryMutex = AtomicBoolean(false) - private suspend fun doTrim( + private fun doTrim( account: Collection, otherAccounts: List, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 5f3da6350..da9cc9ae5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -65,14 +65,11 @@ class LightningAddressResolver { okHttpClient: (String) -> OkHttpClient, context: Context, ): String { - val url = assembleUrl(lnAddress) - - if (url == null) { - throw LightningAddressError( + val url = + assembleUrl(lnAddress) ?: throw LightningAddressError( stringRes(context, R.string.error_unable_to_fetch_invoice), stringRes(context, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress), ) - } val client = okHttpClient(url) @@ -126,12 +123,14 @@ class LightningAddressResolver { okHttpClient: (String) -> OkHttpClient, context: Context, ): String { + @Suppress("BlockingMethodInNonBlockingContext") // URLEncoder.encode is CPU-only, not I/O blocking val encodedMessage = URLEncoder.encode(message, "utf-8") val urlBinder = if (lnCallback.contains("?")) "&" else "?" var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage" if (nostrRequest != null) { + @Suppress("BlockingMethodInNonBlockingContext") // URLEncoder.encode is CPU-only, not I/O blocking val encodedNostrRequest = URLEncoder.encode(nostrRequest.toJson(), "utf-8") url += "&nostr=$encodedNostrRequest" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 5e432c61e..c72cd8fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -55,7 +55,7 @@ class LocationState( object Loading : LocationResult() } - private var hasLocationPermission = MutableStateFlow(false) + private var hasLocationPermission = MutableStateFlow(false) private var latestLocation: LocationResult = LocationResult.Loading fun setLocationPermission(newValue: Boolean) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index 7f4bf2c37..b37a4832c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.service.namecoin +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -43,12 +45,17 @@ class NamecoinNameService( ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val resolver = NamecoinNameResolver(electrumxClient) - private val cache = NamecoinLookupCache() - // Custom server list (user-configurable) + @Volatile private var customServers: List = emptyList() + private val resolver = + NamecoinNameResolver( + electrumxClient = electrumxClient, + serverListProvider = { customServers.ifEmpty { DEFAULT_ELECTRUMX_SERVERS } }, + ) + private val cache = NamecoinLookupCache() + // ── Public API ───────────────────────────────────────────────────── /** @@ -71,6 +78,17 @@ class NamecoinNameService( return result } + /** + * Resolve and return just the hex pubkey, or null. + * Convenience for follow-import integration. + */ + suspend fun resolvePubkey(identifier: String): String? = resolve(identifier)?.pubkey + + /** + * Resolve with detailed outcome for error reporting. + */ + suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome = resolver.resolveDetailed(identifier) + /** * Verify that a Namecoin name maps to the expected pubkey. * @@ -97,7 +115,10 @@ class NamecoinNameService( * * Useful for composable UIs that observe resolution state. */ - fun resolveLive(identifier: String): StateFlow { + fun resolveLive( + identifier: String, + scope: CoroutineScope = this.scope, + ): StateFlow { val state = MutableStateFlow(NamecoinResolveState.Loading) scope.launch { try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt new file mode 100644 index 000000000..4855b493f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt @@ -0,0 +1,96 @@ +/* + * 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.service.namecoin + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import kotlinx.serialization.Serializable + +/** + * Immutable data class representing the current Namecoin resolution config. + * + * When custom servers are configured, they are used EXCLUSIVELY and the + * hardcoded defaults are ignored. This gives privacy-conscious users full + * control over which ElectrumX servers observe their name lookups. + */ +@Serializable +@Stable +data class NamecoinSettings( + /** Whether Namecoin resolution is enabled at all. */ + val enabled: Boolean = true, + /** + * Custom ElectrumX servers. When non-empty, these replace the defaults. + * + * Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext). + */ + val customServers: List = emptyList(), +) { + /** True when the user has configured at least one custom server. */ + val hasCustomServers: Boolean get() = customServers.isNotEmpty() + + /** + * Convert to [ElectrumxServer] instances used by the resolver. + * Returns `null` when no valid custom servers are configured (use defaults). + */ + fun toElectrumxServers(): List? { + if (customServers.isEmpty()) return null + return customServers + .mapNotNull { parseServerString(it) } + .ifEmpty { null } + } + + companion object { + val DEFAULT = NamecoinSettings() + + /** + * Parse `host:port` or `host:port:tcp` into an [ElectrumxServer]. + * + * TLS is the default protocol. Append `:tcp` for plaintext + * (useful for `.onion` addresses and local servers). + * + * `.onion` addresses automatically get `trustAllCerts = true` + * since certificate verification is meaningless over Tor. + */ + fun parseServerString(s: String): ElectrumxServer? { + val parts = s.trim().split(":") + if (parts.size < 2) return null + val host = parts[0].trim() + val port = parts[1].trim().toIntOrNull() ?: return null + if (host.isEmpty() || port <= 0 || port > 65535) return null + val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp" + val isOnion = host.endsWith(".onion") + return ElectrumxServer( + host = host, + port = port, + useSsl = useSsl, + trustAllCerts = isOnion || !useSsl, + ) + } + + /** + * Format an [ElectrumxServer] back to the `host:port[:tcp]` string form. + */ + fun formatServerString(server: ElectrumxServer): String { + val base = "${server.host}:${server.port}" + return if (server.useSsl) base else "$base:tcp" + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt index 367627357..e3c48929d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.okhttp import okhttp3.internal.concurrent.TaskRunner import okhttp3.internal.http2.Http2 import java.io.Closeable +import java.util.Locale import java.util.concurrent.CopyOnWriteArraySet import java.util.logging.ConsoleHandler import java.util.logging.Handler @@ -45,7 +46,7 @@ object OkHttpDebugLogging { level = Level.FINE formatter = object : SimpleFormatter() { - override fun format(record: LogRecord) = String.format("[%1\$tF %1\$tT] %2\$s %n", record.millis, record.message) + override fun format(record: LogRecord) = String.format(Locale.ROOT, $$"[%1$tF %1$tT] %2$s %n", record.millis, record.message) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 146d532e8..9f2ad864a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -48,8 +48,7 @@ class OkHttpWebSocket( override fun needsReconnect(): Boolean { if (socket == null) return true - val myUsingOkHttp = usingOkHttp - if (myUsingOkHttp == null) return true + val myUsingOkHttp = usingOkHttp ?: return true val currentOkHttp = httpClient(url) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 99a0b0883..6b4cb92d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.annotation.OptIn import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf @@ -44,7 +46,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCen import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem -import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform +import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -118,7 +120,11 @@ fun RenderVideoPlayer( contentScale = contentScale, ) - mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) } + AudioPlayingAnimation( + controllerState, + mediaItem.src.waveformData, + Modifier.fillMaxSize().align(Alignment.Center), + ) if (showControls) { TopGradientOverlay( @@ -140,14 +146,14 @@ fun RenderVideoPlayer( accountViewModel = accountViewModel, ) + RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter)) + RenderCenterButtons( controllerState = controllerState, controllerVisible = controllerVisible, modifier = Modifier.align(Alignment.Center), isLiveStream = isLive, ) - - RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index f7e31c162..20ddfc233 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -102,7 +102,7 @@ fun VideoView( ) { val automaticallyStartPlayback = remember { - mutableStateOf( + mutableStateOf( if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 68e285b64..cef95667c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPla import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -public val DEFAULT_MUTED_SETTING = mutableStateOf(true) +val DEFAULT_MUTED_SETTING = mutableStateOf(true) @Composable fun VideoViewInner( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt index 0fe24ca92..e33a001dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt @@ -76,7 +76,7 @@ fun MuteButton( ) { val holdOn = remember { - mutableStateOf( + mutableStateOf( true, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index 491634b68..ab54dcc70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -159,7 +159,7 @@ fun OverflowMenuButton( if (showSave) { DropdownMenuItem( - text = { Text(stringRes(R.string.save_to_gallery), color = Color.White) }, + text = { Text(stringRes(R.string.download_to_phone), color = Color.White) }, onClick = { menuExpanded.value = false onSaveClick() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt index 6b2f6df45..fd28d1aa5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -48,7 +48,7 @@ fun RenderCenterButtons( Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, ) { if (!isLiveStream) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt index 5d46e1748..c3b209ec6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt @@ -132,7 +132,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) { Icon( imageVector = Icons.Default.Download, modifier = Size20Modifier, - contentDescription = stringRes(R.string.save_to_gallery), + contentDescription = stringRes(R.string.download_to_phone), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt index 1a15d7d1f..fb0c9e8d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt @@ -65,7 +65,7 @@ fun VideoPlayerActiveMutex( val videoModifier = remember(controller) { - Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges(view) { bounds, distanceToCenter -> + Modifier.fillMaxWidth().heightIn(min = 120.dp).onVisiblePositionChanges(view) { bounds, distanceToCenter -> controller.visibility.bounds = bounds controller.visibility.distanceToCenter = distanceToCenter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt new file mode 100644 index 000000000..0336e5519 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.wavefront + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData + +fun Tracks.isAudio() = groups.none { it.type == C.TRACK_TYPE_VIDEO } + +@Composable +fun AudioPlayingAnimation( + controllerState: MediaControllerState, + waveform: WaveformData?, + modifier: Modifier = Modifier, +) { + var isAudio by remember { mutableStateOf(controllerState.controller.currentTracks.isAudio()) } + + DisposableEffect(controllerState.controller) { + val listener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + super.onTracksChanged(tracks) + isAudio = tracks.isAudio() + } + } + controllerState.controller.addListener(listener) + onDispose { controllerState.controller.removeListener(listener) } + } + + if (isAudio) { + if (waveform != null) { + Waveform(waveform, controllerState, modifier) + } else { + FakeWaveformAnimation( + mediaControllerState = controllerState, + modifier = modifier, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/FakeWaveformAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/FakeWaveformAnimation.kt new file mode 100644 index 000000000..2e05aa6ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/FakeWaveformAnimation.kt @@ -0,0 +1,153 @@ +/* + * 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.service.playback.composable.wavefront + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onStart +import kotlin.math.sin + +@Composable +fun FakeWaveformAnimation( + mediaControllerState: MediaControllerState, + modifier: Modifier, +) { + val waveformProgress = remember { mutableFloatStateOf(0F) } + + FakeWaveformAnimation(waveformProgress, 50, modifier) + + val restartFlow = remember { mutableIntStateOf(0) } + + // Keeps the screen on while playing and viewing videos. + DisposableEffect(key1 = mediaControllerState.controller) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (isPlaying) { + restartFlow.intValue += 1 + } + } + } + + mediaControllerState.controller.addListener(listener) + onDispose { mediaControllerState.controller.removeListener(listener) } + } + + LaunchedEffect(key1 = restartFlow.intValue) { + pollCurrentPosition(mediaControllerState.controller).collect { value -> + waveformProgress.floatValue = (value % 5000.0f) / 5000.0f + } + } +} + +fun pollCurrentPosition(controller: Player) = + flow { + while (controller.currentPosition <= controller.duration) { + emit(controller.currentPosition) + delay(100) + } + }.onStart { + emit(controller.currentPosition) + }.conflate() + +@Preview +@Composable +fun FakeWaveformAnimationPreview() { + val state = + remember { + mutableFloatStateOf((500 % 1000.0f) / 1000.0f) + } + ThemeComparisonColumn { + FakeWaveformAnimation(state, 50, Modifier.width(200.dp)) + } +} + +@Composable +fun FakeWaveformAnimation( + waveformProgress: MutableFloatState, + barCount: Int = 50, + modifier: Modifier = Modifier, +) { + val barBrush = + Brush.linearGradient( + colors = listOf(Color(0xff2598cf), Color(0xff652d80)), + start = Offset(0.2f * 128f, 0f), + end = Offset(0.2f * 128f + 128f, 0f), + ) + + Canvas( + modifier = + modifier + .fillMaxWidth() + .requiredHeight(48.dp) + .padding(horizontal = 10.dp), + ) { + val barWidth = 3.dp.toPx() + val barPadding = 2.dp.toPx() + val barRadius = 2.dp.toPx() + val totalBarWidth = barWidth + barPadding + val startX = (size.width - (barCount * totalBarWidth - barPadding)) / 2f + + for (i in 0 until barCount) { + val phase = (i.toFloat() / barCount) * 2f * Math.PI.toFloat() + val wave = sin(waveformProgress.floatValue * 2f * Math.PI.toFloat() + phase) + val barFraction = ((wave + 1f) / 2f) + val barHeight = size.height * barFraction + val x = startX + i * totalBarWidth + val y = (size.height - barHeight) / 2f + + drawRoundRect( + brush = barBrush, + topLeft = Offset(x, y), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(barRadius, barRadius), + style = Fill, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt index 71d9d3a16..68fd893c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly import kotlinx.coroutines.delay import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onStart @Composable fun Waveform( @@ -73,22 +74,18 @@ fun Waveform( } LaunchedEffect(key1 = restartFlow.intValue) { - pollCurrentDuration(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value } - } - - LaunchedEffect(Unit) { - delay(500) - val position = mediaControllerState.controller?.let { it.currentPosition / it.duration.toFloat() } ?: 0f - waveformProgress.floatValue = position + pollCurrentRelativePosition(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value } } } -private fun pollCurrentDuration(controller: Player) = +fun pollCurrentRelativePosition(controller: Player) = flow { while (controller.currentPosition <= controller.duration) { emit(controller.currentPosition / controller.duration.toFloat()) delay(100) } + }.onStart { + emit(controller.currentPosition / controller.duration.toFloat()) }.conflate() @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt index 145d8e9dc..a98c8232c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/IntentExtras.kt @@ -77,7 +77,7 @@ class IntentExtras { data.mimeType?.let { putString("mimeType", it) } data.aspectRatio?.let { putFloat("aspectRatio", it) } data.proxyPort?.let { putInt("proxyPort", it) } - data.keepPlaying.let { putBoolean("keepPlaying", it) } + putBoolean("keepPlaying", data.keepPlaying) data.waveformData?.let { putFloatArray("wavefrontData", it.wave.toFloatArray()) } bounds?.let { putInt("boundLeft", it.left) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 765138b3e..4a01cb6ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.service.playback.pip -import android.content.Context.RECEIVER_EXPORTED -import android.os.Build import androidx.annotation.OptIn import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -34,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.state.rememberMuteButtonState @@ -91,13 +90,8 @@ fun RegisterControllerReceiver(controllerState: MediaControllerState) { onPlayPause = controllerState::togglePlayPause, ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(receiver, receiver.filterMute, RECEIVER_EXPORTED) - context.registerReceiver(receiver, receiver.filterPlayPause, RECEIVER_EXPORTED) - } else { - context.registerReceiver(receiver, receiver.filterMute) - context.registerReceiver(receiver, receiver.filterPlayPause) - } + ContextCompat.registerReceiver(context, receiver, receiver.filterMute, ContextCompat.RECEIVER_EXPORTED) + ContextCompat.registerReceiver(context, receiver, receiver.filterPlayPause, ContextCompat.RECEIVER_EXPORTED) onDispose { context.unregisterReceiver(receiver) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 65a0d433b..665530fc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -35,7 +35,7 @@ import okhttp3.OkHttpClient class ExoPlayerBuilder( val okHttp: OkHttpClient, ) { - fun build(context: Context) = + fun build(context: Context): ExoPlayer = ExoPlayer .Builder(context) .apply { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt index ec84d87dd..c48bc6a6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt @@ -26,7 +26,7 @@ class ListWithUniqueSetCache( val key: (T) -> U, ) { private val list = AtomicReference(listOf()) - private val cacheSet = AtomicReference?>(setOf()) + private val cacheSet = AtomicReference?>(setOf()) fun isEmpty() = list.get().isEmpty() @@ -41,7 +41,7 @@ class ListWithUniqueSetCache( } fun distinct(): Set { - var currentSet = cacheSet.get() + val currentSet = cacheSet.get() // Check if the cached set is based on the current list if (currentSet != null) { @@ -49,7 +49,7 @@ class ListWithUniqueSetCache( } // Compute and attempt to atomically update the cache - val newSet = list.get().mapTo(mutableSetOf(), key) + val newSet = list.get().mapTo(mutableSetOf(), key) cacheSet.compareAndSet(currentSet, newSet) return newSet } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index bc00b34b9..6798df8ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand -import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManagerControls import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup @@ -88,7 +87,7 @@ class RelaySubscriptionsCoordinator( val nwc = NWCPaymentFilterAssembler(client) val all = - listOf( + listOf( account, home, chatroomList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt index 08cbe1654..e4525e953 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt @@ -36,7 +36,7 @@ fun filterDraftsFromKey( pubkey: HexKey?, since: Long?, ): List { - if (pubkey == null || pubkey.isEmpty()) return emptyList() + if (pubkey.isNullOrEmpty()) return emptyList() return listOf( RelayBasedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index eb23bfe13..5e70dfedd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -72,7 +73,7 @@ class AccountFollowsLoaderSubAssembler( * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc * and reports, but only from trusted accounts (follows of all logged in users). */ - val hasTried: EOSEAccountFast = EOSEAccountFast(2000) + val hasTried: EOSEAccountFast = EOSEAccountFast(2000) // updates all filters override fun invalidateFilters(ignoreIfDoing: Boolean) { @@ -150,7 +151,10 @@ class AccountFollowsLoaderSubAssembler( } } - if (users.isEmpty()) return null + if (users.isEmpty()) { + hasTried.removeEveryoneBut(emptySet()) + return null + } val connectedRelays = client.connectedRelaysFlow().value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index efbc76b80..fb3457ef6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -73,6 +73,7 @@ fun pickRelaysToLoadUsers( searchRelays - cannotConnectRelays, connected, commonRelays - cannotConnectRelays, + cannotConnectRelays, hasTried, ) } @@ -84,15 +85,16 @@ fun pickRelaysToLoadUsers( searchRelays: Set, connected: Set, commonRelays: Set, + cannotConnectRelays: Set, hasTried: EOSEAccountFast, ): Map> = mapOfSet { users.forEachIndexed { idx, key -> - val tried = hasTried.since(key)?.keys ?: emptySet() + val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays val outbox = key.authorRelayList()?.writeRelaysNorm() - if (outbox != null && outbox.isNotEmpty()) { + if (!outbox.isNullOrEmpty()) { // If there is a home, get from it. // if it tried all outbox relays, stop. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 0b2e2d706..4e58e9531 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -37,9 +37,9 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent @@ -70,7 +70,7 @@ val AccountInfoAndListsFromKeyKinds2 = GeohashListEvent.KIND, TrustProviderListEvent.KIND, PaymentTargetsEvent.KIND, - FavoriteRelayListEvent.KIND, + RelayFeedsListEvent.KIND, ) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 6d78b6a09..854efc620 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -33,9 +33,9 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent @@ -62,7 +62,7 @@ val BasicAccountInfoKinds2 = HashtagListEvent.KIND, GeohashListEvent.KIND, TrustProviderListEvent.KIND, - FavoriteRelayListEvent.KIND, + RelayFeedsListEvent.KIND, ) fun filterBasicAccountInfoFromKeys( @@ -70,7 +70,7 @@ fun filterBasicAccountInfoFromKeys( otherAccounts: List?, since: Long?, ): List { - if (otherAccounts == null || otherAccounts.isEmpty()) return emptyList() + if (otherAccounts.isNullOrEmpty()) return emptyList() return listOf( RelayBasedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index 4ede695a3..cc8ebc0b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -38,7 +38,7 @@ fun filterBookmarksAndReportsFromKey( pubkey: HexKey?, since: Long?, ): List { - if (pubkey == null || pubkey.isEmpty()) return emptyList() + if (pubkey.isNullOrEmpty()) return emptyList() return listOf( RelayBasedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index f3e594d31..9d7c0cdfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -49,7 +49,7 @@ class AccountNotificationsEoseFromInboxRelaysManager( override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, - ): List? = + ): List = key.account.notificationRelays.flow.value.flatMap { filterSummaryNotificationsToPubkey( relay = it, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 55c14ab57..9aa0acc87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -50,7 +50,7 @@ class AccountNotificationsEoseFromRandomRelaysManager( override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, - ): List? { + ): List { // only loads this after the feed is built val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo() return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 02c02efc3..bf7c6659f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -90,7 +90,7 @@ fun filterSummaryNotificationsToPubkey( pubkey: HexKey?, since: Long?, ): List { - if (pubkey == null || pubkey.isEmpty()) return emptyList() + if (pubkey.isNullOrEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -111,7 +111,7 @@ fun filterNotificationsToPubkey( pubkey: HexKey?, since: Long?, ): List { - if (pubkey == null || pubkey.isEmpty()) return emptyList() + if (pubkey.isNullOrEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -142,7 +142,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( pubkey: HexKey?, since: Long?, ): List { - if (pubkey == null || pubkey.isEmpty()) return emptyList() + if (pubkey.isNullOrEmpty()) return emptyList() return listOf( RelayBasedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt index 03481eeae..081f2e486 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt @@ -32,7 +32,7 @@ fun filterGiftWrapsToPubkey( pubkey: HexKey?, since: Long?, ): List { - if (pubkey == null || pubkey.isEmpty()) return emptyList() + if (pubkey.isNullOrEmpty()) return emptyList() return listOf( RelayBasedFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt index a2da47ae6..c14d14bd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -38,7 +38,7 @@ class ChannelLoaderSubAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun updateFilter(keys: List): List? = filterMissingChannelsById(keys) + override fun updateFilter(keys: List): List = filterMissingChannelsById(keys) override fun distinct(key: ChannelFinderQueryState) = key.channel } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt index 616465f9a..251cf0fa2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt @@ -40,7 +40,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } - note.replyTo?.map { parentNote -> + note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } @@ -48,7 +48,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set parentNote.author?.inboxRelays()?.let { set.addAll(it) } } - note.replies.map { childNote -> + note.replies.forEach { childNote -> set.addAll(childNote.relays) LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } @@ -63,7 +63,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set } } - note.boosts.map { childNote -> + note.boosts.forEach { childNote -> set.addAll(childNote.relays) childNote.author?.outboxRelays()?.let { set.addAll(it) } } @@ -71,7 +71,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set return set } -fun filterMissingAddressables(keys: List): List? { +fun filterMissingAddressables(keys: List): List { val addressesPerRelay = mapOfSet { keys.forEach { key -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt index c3a8da9c0..1506da482 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -36,7 +36,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } - note.replyTo?.map { parentNote -> + note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } @@ -44,7 +44,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { parentNote.author?.inboxRelays()?.let { set.addAll(it) } } - note.replies.map { childNote -> + note.replies.forEach { childNote -> set.addAll(childNote.relays) LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } @@ -59,7 +59,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { } } - note.boosts.map { childNote -> + note.boosts.forEach { childNote -> set.addAll(childNote.relays) childNote.author?.outboxRelays()?.let { set.addAll(it) } } @@ -67,7 +67,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { return set } -fun filterMissingEvents(keys: List): List? { +fun filterMissingEvents(keys: List): List { val eventsPerRelay = mapOfSet { keys.forEach { key -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index 2a648f18b..be3e7e252 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -37,7 +37,7 @@ class EventWatcherSubAssembler( allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { var lastNotesOnFilter = emptyList() - var latestEOSEs: EOSEAccountFast = EOSEAccountFast(1000) + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(1000) override fun newEose( relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 859186f4a..779e0206e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -363,16 +363,16 @@ fun observeUserIsFollowingRelay( ): State { val flow = remember(accountViewModel) { - accountViewModel.account.favoriteRelayList.flowNoDefaults + accountViewModel.account.relayFeedsList.flowNoDefaults .mapLatest { relays -> relayUrl in relays }.onStart { - emit(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) + emit(relayUrl in accountViewModel.account.relayFeedsList.flowNoDefaults.value) }.distinctUntilChanged() .flowOn(Dispatchers.IO) } - return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) + return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.relayFeedsList.flowNoDefaults.value) } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 56c9982e3..44316d6b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -49,7 +49,7 @@ class UserOutboxFinderSubAssembler( * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc * and reports, but only from trusted accounts (follows of all logged in users). */ - var hasTried: EOSEAccountFast = EOSEAccountFast(200) + var hasTried: EOSEAccountFast = EOSEAccountFast(200) fun newEose( relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index f749ea41d..19474b6c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -68,7 +68,7 @@ class UserCardsSubAssembler( val trustedAccounts: Map> = mapOfSet { accounts.forEach { account -> - account.homeRelays.flow.value.map { + account.homeRelays.flow.value.forEach { add(it, account.userProfile().pubkeyHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index ce49b1346..fd2e68532 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -67,7 +67,7 @@ class UserReportsSubAssembler( val trustedAccountsPerRelay = mapOfSet { - accounts.map { it.declaredFollowsPerRelay.value }.forEach { + accounts.map { it.declaredFollowsPerOutboxRelay.value }.forEach { add(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 25f386b48..ad37d97fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -42,6 +42,8 @@ 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.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent @@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent val SearchPostsByTextKinds1 = listOf( @@ -87,6 +90,9 @@ val SearchPostsByTextKinds3 = NipTextEvent.KIND, PollEvent.KIND, PollResponseEvent.KIND, + CalendarTimeSlotEvent.KIND, + CalendarDateSlotEvent.KIND, + CodeSnippetEvent.KIND, ) fun searchPostsByText( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt index 9937eac60..345cbd096 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/KindGroup.kt @@ -30,8 +30,8 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi class KindGroup( var count: AtomicInteger = AtomicInteger(0), var memory: AtomicInteger = AtomicInteger(0), - val subs: LargeCache = LargeCache(), - val relays: LargeCache = LargeCache(), + val subs: LargeCache = LargeCache(), + val relays: LargeCache = LargeCache(), ) { companion object { const val MB: Int = 1024 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index c04b6531b..e5e8179c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -32,7 +32,7 @@ typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime open class EOSEByKey( cacheSize: Int = 200, ) { - var followList: LruCache = LruCache(cacheSize) + var followList: LruCache = LruCache(cacheSize) fun addOrUpdate( listCode: U, @@ -99,7 +99,7 @@ open class EOSEAccountKey( class EOSEAccountFast( cacheSize: Int = 20, ) { - private val users: LruCache = LruCache(cacheSize) + private val users: LruCache = LruCache(cacheSize) private val lock = Any() fun addOrUpdate( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt index 0b9ec7042..c517d75f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.tts import android.content.Context import android.media.AudioAttributes -import android.os.Build import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import java.util.Locale @@ -79,15 +78,13 @@ class TextToSpeechEngine private constructor() { engine.setPitch(defaultPitch) engine.setSpeechRate(defaultSpeed) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - val audioAttributes = - AudioAttributes - .Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - engine.setAudioAttributes(audioAttributes) - } + val audioAttributes = + AudioAttributes + .Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + engine.setAudioAttributes(audioAttributes) engine.setListener( onStart = { onStartListener?.invoke() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index ada46f1dd..977b86852 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -49,6 +49,8 @@ class MultiOrchestrator( fun hasVideo() = list.any { it.media.mimeType?.startsWith("video", ignoreCase = true) == true } + fun hasNonMedia() = list.any { it.media.isNotMedia() } + suspend fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 41fad0869..d27935f49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview @@ -96,6 +97,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder @@ -344,7 +346,7 @@ fun EditPostView( it, postViewModel::autocompleteWithUser, accountViewModel, - modifier = Modifier.heightIn(0.dp, 300.dp), + modifier = SuggestionListDefaultHeightPage, ) } @@ -376,6 +378,14 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) { postViewModel.selectImage(it) } + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + if (postViewModel.canAddInvoice) { AddLnInvoiceButton(postViewModel.wantsInvoice) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 347c31d54..00ce6b475 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -111,7 +111,7 @@ open class EditPostViewModel : ViewModel() { this.editedFromNote = edit this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountViewModel.account) + this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05Client) } fun sendPost() { @@ -275,8 +275,13 @@ open class EditPostViewModel : ViewModel() { if (it.selection.collapsed) { val lastWord = message.currentWord() - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 1263816fc..6bdb0a528 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge @@ -204,7 +205,8 @@ fun ImageVideoPost( fileServers .firstOrNull { it == accountViewModel.account.settings.defaultFileServer } ?.name - ?: fileServers[0].name, + ?: fileServers.firstOrNull()?.name + ?: DEFAULT_MEDIA_SERVERS[0].name, options = fileServerOptions, onSelect = { postViewModel.selectedServer = fileServers[it] }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index ee3810e72..459a703c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -252,5 +252,5 @@ interface Dao { suspend fun getOrCreateNote(hex: String): Note - suspend fun getOrCreateAddressableNote(address: Address): AddressableNote? + fun getOrCreateAddressableNote(address: Address): AddressableNote? } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt new file mode 100644 index 000000000..354853720 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt @@ -0,0 +1,129 @@ +/* + * 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.actions.uploads + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.concurrent.atomic.AtomicBoolean + +@Composable +fun SelectFromFiles( + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onFilesChosen: (ImmutableList) -> Unit, +) { + var showFileSelect by remember { mutableStateOf(false) } + if (showFileSelect) { + FileSelect( + onFilesSelected = { files -> + showFileSelect = false + if (files.isNotEmpty()) { + onFilesChosen(files) + } + }, + ) + } + + FileSelectButton(isUploading, tint, modifier) { showFileSelect = true } +} + +@Composable +private fun FileSelectButton( + isUploading: Boolean, + tint: Color, + modifier: Modifier, + onClick: () -> Unit, +) { + IconButton( + modifier = modifier, + enabled = !isUploading, + onClick = { onClick() }, + ) { + if (!isUploading) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = stringRes(id = R.string.upload_file), + modifier = Modifier.height(20.dp), + tint = tint, + ) + } else { + LoadingAnimation() + } + } +} + +@Composable +fun FileSelect(onFilesSelected: (ImmutableList) -> Unit = {}) { + val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) } + val resolver = LocalContext.current.contentResolver + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments(), + onResult = { uris: List -> + onFilesSelected( + uris + .map { + SelectedMedia(it, resolver.getType(it)) + }.toImmutableList(), + ) + hasLaunched.set(false) + }, + ) + + @Composable + fun LaunchFilePicker() { + SideEffect { + if (!hasLaunched.getAndSet(true)) { + launcher.launch( + arrayOf( + "audio/*", + "application/pdf", + ), + ) + } + } + } + + LaunchFilePicker() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index d502dd069..0e38451c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -55,6 +55,15 @@ class SelectedMedia( fun isImage() = mimeType?.startsWith("image") fun isVideo() = mimeType?.startsWith("video") + + fun isAudio() = mimeType?.startsWith("audio") + + fun isNotMedia() = + mimeType?.let { + !(it.startsWith("image") || it.startsWith("video") || it.startsWith("audio")) + } ?: true + + fun isDocument() = mimeType == "application/pdf" } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt index 04e6c1e34..f7c153611 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt @@ -28,14 +28,21 @@ import android.os.Build import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults @@ -117,6 +124,16 @@ fun ShowImageGallery( .fillMaxWidth() .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), ) + } else if (media.isAudio() == true) { + FilePreviewPlaceholder( + icon = Icons.Default.AudioFile, + label = media.mimeType ?: "audio/*", + ) + } else if (media.isDocument()) { + FilePreviewPlaceholder( + icon = Icons.Default.PictureAsPdf, + label = media.mimeType ?: "application/pdf", + ) } else if (media.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { var bitmap by remember { mutableStateOf(null) } val context = LocalContext.current @@ -219,38 +236,76 @@ fun UploadingState( progress: Double, progressState: UploadingState, ) { - Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { - val animatedProgress by animateFloatAsState( - targetValue = progress.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) { + val animatedProgress by animateFloatAsState( + targetValue = progress.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) - val txt = - when (progressState) { - is UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is UploadingState.Error -> stringRes(R.string.uploading_state_error) - } + val txt = + when (progressState) { + is UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is UploadingState.Error -> stringRes(R.string.uploading_state_error) + } - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +fun FilePreviewPlaceholder( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt index 9f2dc5433..b23559684 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AudioWaveformReadOnly.kt @@ -170,7 +170,7 @@ internal fun Iterable.fillToSize( transform: (List) -> T, ): List { val capacity = ceil(size.safeDiv(count())).roundToInt() - return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform) + return flatMap { data -> List(capacity) { data } }.chunkToSize(size, transform) } internal fun Iterable.chunkToSize( @@ -207,6 +207,4 @@ internal fun Iterable.normalize( return values.map { scale * ((it - currentMin) / range) + min } } -private fun Int.safeDiv(value: Int): Float { - return if (value == 0) return 0F else this / value.toFloat() -} +private fun Int.safeDiv(value: Int): Float = if (value == 0) 0F else this / value.toFloat() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index ad2498aad..125d03248 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -108,7 +108,7 @@ fun LoadOrCreateNote( content: @Composable (Note?) -> Unit, ) { var note by - remember(event.id) { mutableStateOf(accountViewModel.getNoteIfExists(event.id)) } + remember(event.id) { mutableStateOf(accountViewModel.getNoteIfExists(event.id)) } if (note == null) { LaunchedEffect(key1 = event.id) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 060b751d9..1dd39aa23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -169,6 +169,10 @@ fun RenderStrangeNamePreview() { is RelayUrlSegment -> { ClickableRelayUrl(word.segmentText, EmptyNav()) } + + is SchemelessUrlSegment -> { + NoProtocolUrlRenderer(word.segmentText) + } } } } @@ -219,13 +223,17 @@ fun RenderRegularPreview() { // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> { - NoProtocolUrlRenderer(word) + is LinkSegment -> { + ClickableUrl(word.segmentText, word.segmentText) } is RegularTextSegment -> { Text(word.segmentText) } + + is SchemelessUrlSegment -> { + NoProtocolUrlRenderer(word.segmentText) + } } } } @@ -257,11 +265,13 @@ fun RenderRegularPreview2() { // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) + is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) is RegularTextSegment -> Text(word.segmentText) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, EmptyNav()) + + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } } } @@ -307,11 +317,12 @@ fun RenderRegularPreview3() { // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, EmptyNav()) + + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } } } @@ -485,11 +496,11 @@ private fun RenderWordWithoutPreview( is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) - is RegularTextSegment -> Text(word.segmentText) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) + + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } } @@ -518,10 +529,10 @@ private fun RenderWordWithPreview( is HashTagSegment -> HashTag(word, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) - is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) + is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } } @@ -539,9 +550,8 @@ private fun ZoomableContentView( } @Composable -private fun NoProtocolUrlRenderer(segment: SchemelessUrlSegment) { - ClickableUrl(segment.url, "https://${segment.url}") - segment.extras?.let { it1 -> Text(it1) } +private fun NoProtocolUrlRenderer(url: String) { + ClickableUrl(url, "https://$url") } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 18b45ffc4..29eb4d8a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW @Composable @@ -79,17 +80,9 @@ fun SensitivityWarning( ) { val hasSensitiveContent = remember(event) { event.isSensitiveOrNSFW() } - SensitivityWarning(hasSensitiveContent, accountViewModel, content) -} - -@Composable -fun SensitivityWarning( - hasSensitiveContent: Boolean, - accountViewModel: AccountViewModel, - content: @Composable () -> Unit, -) { if (hasSensitiveContent) { - SensitivityWarning(accountViewModel, content) + val reason = remember(event) { event.contentWarningReason() } + ObserveSensitivityWarning(reason, accountViewModel, content) } else { content() } @@ -97,6 +90,20 @@ fun SensitivityWarning( @Composable fun SensitivityWarning( + reason: String?, + accountViewModel: AccountViewModel, + content: @Composable () -> Unit, +) { + if (reason != null) { + ObserveSensitivityWarning(reason, accountViewModel, content) + } else { + content() + } +} + +@Composable +fun ObserveSensitivityWarning( + reason: String?, accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { @@ -106,7 +113,7 @@ fun SensitivityWarning( CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { if (it) { - ContentWarningNote { showContentWarningNote = false } + ContentWarningNote(reason) { showContentWarningNote = false } } else { content() } @@ -117,12 +124,31 @@ fun SensitivityWarning( @Composable fun ContentWarningNotePreview() { ThemeComparisonColumn { - ContentWarningNote({}) + ContentWarningNote(null) {} + } +} + +@Preview +@Composable +fun ContentWarningNoteWithReasonPreview() { + ThemeComparisonColumn { + ContentWarningNote("Spoilers") {} + } +} + +@Preview +@Composable +fun ContentWarningNoteWithBigReasonPreview() { + ThemeComparisonColumn { + ContentWarningNote("Spoilers, monkeys, bannanas, and other things") {} } } @Composable -fun ContentWarningNote(onDismiss: () -> Unit) { +fun ContentWarningNote( + reason: String?, + onDismiss: () -> Unit, +) { Column { Row(modifier = PaddingHorizontal12Modifier) { Column(modifier = Modifier.padding(start = 10.dp)) { @@ -155,19 +181,28 @@ fun ContentWarningNote(onDismiss: () -> Unit) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Text( - text = stringRes(R.string.content_warning), + text = + if (reason.isNullOrBlank()) { + stringRes(R.string.content_warning) + } else { + stringRes(R.string.content_warning_with_reason, reason) + }, fontWeight = FontWeight.Bold, fontSize = 18.sp, + softWrap = true, + textAlign = TextAlign.Center, ) } - Row { - Text( - text = stringRes(R.string.content_warning_explanation), - color = Color.Gray, - modifier = Modifier.padding(top = 10.dp), - textAlign = TextAlign.Center, - ) + if (reason.isNullOrBlank()) { + Row { + Text( + text = stringRes(R.string.content_warning_explanation), + color = Color.Gray, + modifier = Modifier.padding(top = 10.dp), + textAlign = TextAlign.Center, + ) + } } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 7a7746689..2fce2a808 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -290,7 +290,7 @@ private fun DialogContent( Icon( imageVector = Icons.Default.Download, modifier = Size20Modifier, - contentDescription = stringRes(R.string.save_to_gallery), + contentDescription = stringRes(R.string.download_to_phone), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index f8cc1c82a..e998711ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -140,7 +140,7 @@ fun ZoomableContentView( when (content) { is MediaUrlImage -> { - SensitivityWarning(content.contentWarning != null, accountViewModel) { + SensitivityWarning(content.contentWarning, accountViewModel) { TwoSecondController(content) { controllerVisible -> val mainImageModifier = Modifier @@ -153,7 +153,7 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - SensitivityWarning(content.contentWarning != null, accountViewModel) { + SensitivityWarning(content.contentWarning, accountViewModel) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { VideoView( videoUri = content.url, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt index ba25e3a42..7b53a2fdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -49,7 +49,7 @@ class ChannelFeedContentState( val feedContent = _feedContent.asStateFlow() // Simple counter that changes when it needs to invalidate everything - private val _scrollToTop = MutableStateFlow(0) + private val _scrollToTop = MutableStateFlow(0) val scrollToTop = _scrollToTop.asStateFlow() var scrolltoTopPending = false @@ -64,7 +64,7 @@ class ChannelFeedContentState( viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } } - suspend fun sentToTop() { + fun sentToTop() { scrolltoTopPending = false } @@ -98,10 +98,10 @@ class ChannelFeedContentState( if (notes.isEmpty()) { _feedContent.tryEmit(ChannelFeedState.Empty) } else if (currentState is ChannelFeedState.Loaded) { - currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey())) + currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey())) } else { _feedContent.tryEmit( - ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))), + ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index fbc6b5135..2cc7b942c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -54,6 +54,8 @@ object ScrollStateKeys { const val DISCOVER_LIVE = "DiscoverLiveFeed" const val DISCOVER_COMMUNITY = "DiscoverCommunitiesFeed" const val DISCOVER_CHATS = "DiscoverChatsFeed" + + const val SEARCH_SCREEN = "SearchFeed" } object PagerStateKeys { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt index 4972c960f..68281cf55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt @@ -18,7 +18,7 @@ * 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.myapplication +package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt index 6cdf473fc..0da9167d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt @@ -18,7 +18,7 @@ * 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.myapplication +package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 8bb60a28b..3096fb99a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -32,8 +32,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.myapplication.DisappearingBottomBar -import com.vitorpamplona.myapplication.DisappearingFloatingButton @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f032e1825..2bb646e98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -97,6 +97,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleList import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -109,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScre import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen @@ -121,6 +124,7 @@ import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -172,11 +176,21 @@ fun AppNavigation( composableFromEnd { AccountBackupScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } + composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } + composableFromEndArgs { + ImportFollowListPickFollowsScreen( + accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(it.userHex)), + accountViewModel, + nav, + ) + } + composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 4cbd74bfe..9c2dfa282 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -130,7 +130,7 @@ fun DisplayAccount( accountSessionManager: AccountSessionManager, ) { var baseUser by remember(acc) { - mutableStateOf( + mutableStateOf( decodePublicKeyAsHexOrNull(acc.npub)?.let { LocalCache.getUserIfExists(it) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index e12771d27..8ca73fb97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -472,6 +472,14 @@ fun ListContent( route = Route.Chess, ) + NavigationRow( + title = R.string.route_import_follows, + icon = Icons.Outlined.GroupAdd, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.ImportFollowsSelectUser, + ) + IconRowRelays( accountViewModel = accountViewModel, onClick = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 7956d1e77..f3087a352 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -87,7 +87,7 @@ class Nav( ) { navigationScope.launch { controller.navigate(route) { - popUpTo(klass) { inclusive = true } + popUpTo(klass) { inclusive = true } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 25bdca7df..1f06f23b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -323,7 +323,7 @@ suspend fun routeEditDraftTo( is ChatroomKeyable -> { val room = draft.chatroomKey(account.userProfile().pubkeyHex) account.chatroomList.getOrCreatePrivateChatroom(room) - return Route.Room(room, draftId = note.idHex) + Route.Room(room, draftId = note.idHex) } is TextNoteEvent -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index ef789d612..b55b4dd06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -49,10 +49,18 @@ sealed class Route { @Serializable object PrivacyOptions : Route() + @Serializable object NamecoinSettings : Route() + @Serializable object Bookmarks : Route() @Serializable object BookmarkGroups : Route() + @Serializable object ImportFollowsSelectUser : Route() + + @Serializable data class ImportFollowsPickFollows( + val userHex: HexKey, + ) : Route() + @Serializable data class BookmarkGroupView( val dTag: String, val bookmarkType: BookmarkType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index ed4d1888e..62f721767 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -113,12 +113,6 @@ fun FeedFilterSpinner( } } - val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - - LaunchedEffect(locationPermissionState.status.isGranted) { - Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted) - } - val accessibilityDescription = if (selected != null) { stringRes(R.string.feed_filter_selected, currentText) @@ -137,6 +131,7 @@ fun FeedFilterSpinner( Text(currentText) if (selected?.code is TopFilter.AroundMe) { + val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) if (!locationPermissionState.status.isGranted) { LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index ebd0913b7..b00c1735a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -98,7 +98,7 @@ fun HiddenNotePreview() { ThemeComparisonColumn( toPreview = { HiddenNote( - reports = persistentSetOf(), + reports = persistentSetOf(), isHiddenAuthor = true, accountViewModel = mockAccountViewModel(), nav = EmptyNav(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 281f797ae..eaee4475c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Tunestr @@ -127,7 +126,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine( if (nip05VerifState.isExpired()) { LaunchedEffect(key1 = nip05VerifState) { accountViewModel.runOnIO { - nip05State.checkAndUpdate(Amethyst.instance.nip05Client) + nip05State.checkAndUpdate(accountViewModel.nip05Client) } } } @@ -167,7 +166,7 @@ fun ObserveAllStatusesToAvoidSwitchigAllTheTime( statuses: ImmutableList, accountViewModel: AccountViewModel, ) { - statuses.map { + statuses.forEach { EventFinderFilterAssemblerSubscription(it, accountViewModel) } } @@ -393,7 +392,7 @@ fun ObserveAndRenderNIP05VerifiedSymbol( if (state.isExpired()) { LaunchedEffect(key1 = state) { accountViewModel.runOnIO { - nip05State.checkAndUpdate(Amethyst.instance.nip05Client) + nip05State.checkAndUpdate(accountViewModel.nip05Client) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 53a400164..e85bb5caf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -81,12 +81,12 @@ import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList -import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayRelayFeedsList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList @@ -99,11 +99,14 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward +import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds +import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource @@ -204,11 +207,13 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -238,8 +243,10 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext @Composable fun NoteCompose( @@ -536,7 +543,7 @@ fun ClickableNote( nav.nav { if (redirectToNote.event is DraftWrapEvent) { - with(Dispatchers.IO) { + withContext(Dispatchers.IO) { routeEditDraftTo(redirectToNote, accountViewModel.account) } } else { @@ -785,6 +792,10 @@ private fun RenderNoteRow( RenderLongFormContent(baseNote, accountViewModel, nav) } + is CodeSnippetEvent -> { + RenderCodeSnippetEvent(baseNote) + } + is WikiNoteEvent -> { RenderWikiContent(baseNote, accountViewModel, nav) } @@ -837,8 +848,8 @@ private fun RenderNoteRow( DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) } - is FavoriteRelayListEvent -> { - DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) + is RelayFeedsListEvent -> { + DisplayRelayFeedsList(baseNote, backgroundColor, accountViewModel, nav) } is IndexerRelayListEvent -> { @@ -967,6 +978,14 @@ private fun RenderNoteRow( ) } + is CalendarTimeSlotEvent -> { + RenderCalendarTimeSlotEvent(baseNote, accountViewModel, nav) + } + + is CalendarDateSlotEvent -> { + RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) + } + is HighlightEvent -> { RenderHighlight( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 85dbd35e2..f6e92f3c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -481,7 +481,7 @@ private fun ReactionDetailGallery( accountViewModel: AccountViewModel, ) { val defaultBackgroundColor = MaterialTheme.colorScheme.background - val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } + val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) } val hasReactions by observeNoteReferences(baseNote, accountViewModel) @@ -757,7 +757,7 @@ private fun SlidingAnimationCount( if (accountViewModel.settings.isPerformanceMode()) { TextCount(baseCount, textColor) } else { - AnimatedContent( + AnimatedContent( targetState = baseCount, transitionSpec = AnimatedContentTransitionScope::transitionSpec, label = "SlidingAnimationCount", @@ -1117,6 +1117,9 @@ fun ZapReaction( nav.nav(Route.ManualZapSplitPayment(uid)) } }, + onCustomAmount = { + wantsToSetCustomZap = true + }, ) } }, @@ -1248,6 +1251,7 @@ fun zapClick( onZapStarts: () -> Unit, onZappingProgress: (Float) -> Unit, onMultipleChoices: () -> Unit, + onCustomAmount: () -> Unit, onError: (String, String, User?) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { @@ -1262,10 +1266,7 @@ fun zapClick( val choices = accountViewModel.zapAmountChoices() if (choices.isEmpty()) { - accountViewModel.toastManager.toast( - R.string.error_dialog_zap_error, - R.string.no_zap_amount_setup_long_press_to_change, - ) + onCustomAmount() } else if (!accountViewModel.isWriteable()) { accountViewModel.toastManager.toast( R.string.error_dialog_zap_error, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 1a50d6e23..bf760560e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -86,8 +86,8 @@ fun ReplyInformationChannel( onUserTagClick: (User) -> Unit, ) { FlowRow { - if (mentions != null && mentions.isNotEmpty()) { - if (replyTo != null && replyTo.isNotEmpty()) { + if (!mentions.isNullOrEmpty()) { + if (!replyTo.isNullOrEmpty()) { Text( stringRes(id = R.string.replying_to), fontSize = 13.sp, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 99d2cc893..107bd36aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -32,7 +32,7 @@ import kotlin.math.round private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" private const val MONTH_DATE_FORMAT = "MMM dd" -var locale = Locale.getDefault() +var locale: Locale = Locale.getDefault() var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 52859f724..ca7afac13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -31,6 +31,19 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -42,20 +55,28 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.AddCircle import androidx.compose.material.icons.outlined.ContentPaste +import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -75,24 +96,48 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.toImmutableList +@OptIn(ExperimentalLayoutApi::class) +@Composable +@Preview(device = "spec:width=1900px,height=2340px,dpi=440") +fun UpdateZapAmountContentPreview() { + val accountViewModel = mockAccountViewModel() + val vm: UpdateZapAmountViewModel = viewModel() + vm.init(accountViewModel) + + ThemeComparisonRow { + UpdateZapAmountContent( + postViewModel = vm, + onClose = {}, + accountViewModel = accountViewModel, + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun UpdateZapAmountContent( @@ -103,7 +148,6 @@ fun UpdateZapAmountContent( ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current - val uri = LocalUriHandler.current val zapTypes = @@ -155,46 +199,82 @@ fun UpdateZapAmountContent( } } + var qrScanning by remember { mutableStateOf(false) } + + // Expand manual config automatically when a wallet connection exists + var showManualConfig by remember { mutableStateOf(postViewModel.walletConnectPubkey.text.isNotBlank()) } + LaunchedEffect(postViewModel.walletConnectPubkey.text) { + if (postViewModel.walletConnectPubkey.text.isNotBlank()) { + showManualConfig = true + } + } + Column( modifier = Modifier - .padding(10.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) .fillMaxWidth() .imePadding() .verticalScroll(rememberScrollState()), ) { + // ── Section 1: Quick Zap Amounts ────────────────────────────────────── + + Text( + text = stringRes(R.string.quick_zap_amounts), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + modifier = SettingsCategoryFirstModifier, + ) + Text( + text = stringRes(R.string.quick_zap_amounts_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(bottom = 6.dp), + ) + + // Amount chips — animateContentSize gives a smooth expand/collapse when + // chips are added or removed FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { postViewModel.amountSet.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + InputChip( + selected = false, onClick = { postViewModel.removeAmount(amountInSats) }, - ) { - Text( - "⚡ ${ - showAmount( - amountInSats.toBigDecimal().setScale(1), - ) - } ✖", - color = Color.White, - textAlign = TextAlign.Center, - ) - } + label = { + Text( + text = "⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))}", + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringRes(R.string.remove), + modifier = Modifier.size(InputChipDefaults.AvatarSize), + ) + }, + colors = + InputChipDefaults.inputChipColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + labelColor = MaterialTheme.colorScheme.primary, + trailingIconColor = MaterialTheme.colorScheme.primary, + ), + ) } } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(6.dp)) + // Add new amount Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { OutlinedTextField( label = { Text(text = stringRes(R.string.new_amount_in_sats)) }, @@ -211,79 +291,167 @@ fun UpdateZapAmountContent( color = MaterialTheme.colorScheme.placeholderText, ) }, + trailingIcon = { + IconButton( + onClick = postViewModel::addAmount, + shape = ButtonBorder, + enabled = postViewModel.nextAmount.text.isNotBlank(), + ) { + Icon( + imageVector = Icons.Outlined.AddCircle, + contentDescription = stringRes(R.string.add), + modifier = Size20Modifier, + ) + } + }, singleLine = true, - modifier = Modifier.padding(end = 10.dp).weight(1f), - ) - - Button( - onClick = { postViewModel.addAmount() }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringRes(R.string.add), color = Color.White) - } - } - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextSpinner( - label = stringRes(id = R.string.zap_type_explainer), - placeholder = zapTypes.firstOrNull { it.first == accountViewModel.defaultZapType() }?.second ?: zapTypes.firstOrNull()?.second ?: "", - options = zapOptions, - onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, - modifier = Modifier.weight(1f).padding(end = 5.dp), + modifier = Modifier.weight(1f), ) } - HorizontalDivider( - modifier = Modifier.padding(vertical = 10.dp), - thickness = DividerThickness, + // ── Section 2: Zap Privacy ──────────────────────────────────────────── + + Text( + text = stringRes(R.string.zap_privacy_section), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + modifier = SettingsCategorySpacingModifier, + ) + Text( + text = stringRes(R.string.zap_type_section_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(bottom = 8.dp), ) - - var qrScanning by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Text( - stringRes(id = R.string.wallet_connect_service), - Modifier.weight(1f), + TextSpinner( + label = stringRes(id = R.string.zap_type_explainer), + placeholder = + zapTypes + .firstOrNull { it.first == accountViewModel.defaultZapType() } + ?.second + ?: zapTypes.firstOrNull()?.second + ?: "", + options = zapOptions, + onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, + modifier = Modifier.fillMaxWidth(), ) + } - IconButton( + // ── Section 3: Nostr Wallet Connect ─────────────────────────────────── + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = DividerThickness, + ) + + // Section header + connection status indicator + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.wallet_connect_service), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.wallet_connect_service_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + + // Animated connection status badge + val isConnected = postViewModel.walletConnectPubkey.text.isNotBlank() + val statusColor by animateColorAsState( + targetValue = if (isConnected) Color(0xFF4CAF50) else MaterialTheme.colorScheme.placeholderText, + animationSpec = tween(durationMillis = 400), + label = "nwc_status_color", + ) + + AnimatedContent( + targetState = isConnected, + transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }, + label = "nwc_status_badge", + ) { connected -> + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = if (connected) Icons.Filled.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(18.dp), + ) + Text( + text = + if (connected) { + stringRes(R.string.wallet_connect_status_connected) + } else { + stringRes(R.string.wallet_connect_status_not_connected) + }, + color = statusColor, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + // Connect action buttons + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + // Primary "Connect Wallet" button — opens the NWC app deep link + OutlinedButton( + modifier = Modifier.weight(1f), + shape = ButtonBorder, onClick = { - onClose() - try { - uri.openUri("nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc") + uri.openUri( + "nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc", + ) + onClose() } catch (_: IllegalArgumentException) { - accountViewModel.toastManager.toast(R.string.couldnt_find_nwc_wallets, R.string.couldnt_find_nwc_wallets_description) + accountViewModel.toastManager.toast( + R.string.couldnt_find_nwc_wallets, + R.string.couldnt_find_nwc_wallets_description, + ) } }, ) { Icon( imageVector = Icons.Outlined.Add, - contentDescription = stringRes(id = R.string.connect_to_new_nwc_wallet), - modifier = Size24Modifier, - tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier.size(18.dp), ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringRes(R.string.wallet_connect_connect_app)) } + Spacer(DoubleHorzSpacer) + + // Paste from clipboard IconButton( onClick = { - val uri = clipboardManager.getText()?.text + val clipText = clipboardManager.getText()?.text try { - uri?.let { - postViewModel.copyFromClipboard(it) - } + clipText?.let { postViewModel.copyFromClipboard(it) } } catch (e: IllegalArgumentException) { - accountViewModel.toastManager.toast(R.string.invalid_nip47_uri_title, R.string.invalid_nip47_uri_description, uri ?: "") + accountViewModel.toastManager.toast( + R.string.invalid_nip47_uri_title, + R.string.invalid_nip47_uri_description, + clipText ?: "", + ) } }, ) { @@ -295,6 +463,7 @@ fun UpdateZapAmountContent( ) } + // QR code scanner IconButton(onClick = { qrScanning = true }) { Icon( painter = painterRes(R.drawable.ic_qrcode, 3), @@ -305,18 +474,6 @@ fun UpdateZapAmountContent( } } - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringRes(id = R.string.wallet_connect_service_explainer), - Modifier.weight(1f), - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font14SP, - ) - } - if (qrScanning) { SimpleQrCodeScanner { qrScanning = false @@ -340,121 +497,153 @@ fun UpdateZapAmountContent( } } + // Expandable manual configuration section Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { showManualConfig = !showManualConfig } + .padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.wallet_connect_service_pubkey)) }, - value = postViewModel.walletConnectPubkey, - onValueChange = { postViewModel.walletConnectPubkey = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - ), - placeholder = { - Text( - text = "npub, hex", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, + Text( + text = stringRes(R.string.wallet_connect_manual_config), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.weight(1f), + fontSize = Font14SP, + ) + Icon( + imageVector = if (showManualConfig) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(20.dp), ) } - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, + AnimatedVisibility( + visible = showManualConfig, + enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeIn(), + exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeOut(), ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.wallet_connect_service_relay)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectRelay, - onValueChange = { postViewModel.walletConnectRelay = it }, - placeholder = { - Text( - text = "wss://relay.server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.wallet_connect_service_pubkey)) }, + value = postViewModel.walletConnectPubkey, + onValueChange = { postViewModel.walletConnectPubkey = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + ), + placeholder = { + Text( + text = "npub, hex", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), ) - }, - singleLine = true, - ) - } - - var showPassword by remember { mutableStateOf(false) } - - val context = LocalContext.current - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - showPassword = true } - } - val authTitle = stringRes(id = R.string.wallet_connect_service_show_secret) - - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.wallet_connect_service_secret)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectSecret, - onValueChange = { postViewModel.walletConnectSecret = it }, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.wallet_connect_service_secret_placeholder), - color = MaterialTheme.colorScheme.placeholderText, + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.wallet_connect_service_relay)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.walletConnectRelay, + onValueChange = { postViewModel.walletConnectRelay = it }, + placeholder = { + Text( + text = "wss://relay.server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, ) - }, - trailingIcon = { - IconButton( - onClick = { - if (!showPassword) { - authenticate( - title = authTitle, - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { showPassword = true }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + } + + var showPassword by remember { mutableStateOf(false) } + + val secretContext = LocalContext.current + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + showPassword = true + } + } + + val authTitle = stringRes(id = R.string.wallet_connect_service_show_secret) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.wallet_connect_service_secret)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.walletConnectSecret, + onValueChange = { postViewModel.walletConnectSecret = it }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringRes(R.string.wallet_connect_service_secret_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + IconButton( + onClick = { + if (!showPassword) { + authenticate( + title = authTitle, + context = secretContext, + keyguardLauncher = keyguardLauncher, + onApproved = { showPassword = true }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, + ) + } else { + showPassword = false + } + }, + ) { + Icon( + imageVector = + if (showPassword) { + Icons.Outlined.VisibilityOff + } else { + Icons.Outlined.Visibility + }, + contentDescription = + if (showPassword) { + stringRes(R.string.show_password) + } else { + stringRes(R.string.hide_password) + }, ) - } else { - showPassword = false } }, - ) { - Icon( - imageVector = - if (showPassword) { - Icons.Outlined.VisibilityOff - } else { - Icons.Outlined.Visibility - }, - contentDescription = - if (showPassword) { - stringRes(R.string.show_password) - } else { - stringRes( - R.string.hide_password, - ) - }, - ) - } - }, - visualTransformation = - if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - ) + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + ) + } + } } + + Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index a5697d7d0..79a211901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -498,7 +498,7 @@ fun ScoreTag55Preview() { size = 18.dp Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { InnerUserPicture( - userHex = "AABBCC", + userHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", userPicture = PREVIEW_PICTURE_URL, userName = PREVIEW_USER_NAME, size = size, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 8cb381999..9d4b31d4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -262,7 +262,7 @@ class PollNoteViewModel : ViewModel() { valueMinimum?.let { minimum -> valueMaximum?.let { maximum -> if (minimum != maximum) { - options.add(((minimum + maximum) / 2).toLong()) + options.add(((minimum + maximum) / 2)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt index 8014c4c2a..77dbf33fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/contentWarning/ContentSensitivityExplainer.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -48,7 +49,10 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable -fun ContentSensitivityExplainer() { +fun ContentSensitivityExplainer( + description: String, + onDescriptionChange: (String) -> Unit, +) { Column(Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -97,5 +101,18 @@ fun ContentSensitivityExplainer() { color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(vertical = 10.dp), ) + + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringRes(R.string.add_sensitive_content_description_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt index d996b105a..a014a1627 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/draftTags/DraftTagState.kt @@ -34,9 +34,9 @@ import kotlin.uuid.Uuid @Stable class DraftTagState { var current: String by mutableStateOf(newTag()) - var usedDraftTags by mutableStateOf(setOf(current)) + var usedDraftTags by mutableStateOf(setOf(current)) - private val _versions = MutableStateFlow(0) + private val _versions = MutableStateFlow(0) @OptIn(FlowPreview::class) val versions = _versions.debounce(1000) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt index f0709d893..55f7724ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -55,7 +54,7 @@ fun ShowEmojiSuggestionList( emojiSuggestions: EmojiSuggestionState, onSelect: (EmojiPackState.EmojiMedia) -> Unit, onFullSize: (EmojiPackState.EmojiMedia) -> Unit, - modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), + modifier: Modifier = Modifier, ) { val suggestions by emojiSuggestions.results.collectAsStateWithLifecycle(emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt index b6763d17a..9a50d8bf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.previews +import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.service.CachedUrlParser import kotlinx.coroutines.Dispatchers @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +@Stable class PreviewState { var source = MutableStateFlow(TextFieldValue("")) @@ -37,8 +39,9 @@ class PreviewState { val results = source .debounce(500) - .map { CachedUrlParser.parseValidUrls(it.text) } - .distinctUntilChanged() + .map { + CachedUrlParser.parseValidUrls(it.text) + }.distinctUntilChanged() .flowOn(Dispatchers.IO) fun reset() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 075f0aaf7..9e40f6e1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery @@ -92,14 +93,16 @@ fun ImageVideoDescription( var selectedServer by remember { mutableStateOf( - fileServers.firstOrNull { it == defaultServer } ?: fileServers[0], + fileServers.firstOrNull { it == defaultServer } ?: fileServers.firstOrNull() ?: DEFAULT_MEDIA_SERVERS[0], ) } var message by remember { mutableStateOf("") } var sensitiveContent by remember { mutableStateOf(false) } // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED - var mediaQualitySlider by remember { mutableIntStateOf(1) } + var mediaQualitySlider by remember { + mutableIntStateOf(if (uris.hasNonMedia()) 3 else 1) + } // Codec selection: false = H264, true = H265 var useH265Codec by remember { mutableStateOf(false) } @@ -189,7 +192,8 @@ fun ImageVideoDescription( fileServers .firstOrNull { it == defaultServer } ?.name - ?: fileServers[0].name, + ?: fileServers.firstOrNull()?.name + ?: DEFAULT_MEDIA_SERVERS[0].name, options = fileServerOptions, onSelect = { selectedServer = fileServers[it] }, modifier = @@ -270,27 +274,31 @@ fun ImageVideoDescription( } } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box(modifier = Modifier.fillMaxWidth()) { - Text( - text = - when (mediaQualitySlider) { - 0 -> stringRes(R.string.media_compression_quality_low) - 1 -> stringRes(R.string.media_compression_quality_medium) - 2 -> stringRes(R.string.media_compression_quality_high) - 3 -> stringRes(R.string.media_compression_quality_uncompressed) - else -> stringRes(R.string.media_compression_quality_medium) - }, - modifier = Modifier.align(Alignment.Center), + val firstMedia = uris.first().media + + if (firstMedia.isVideo() == true || firstMedia.isImage() == true || firstMedia.isAudio() == true) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = + when (mediaQualitySlider) { + 0 -> stringRes(R.string.media_compression_quality_low) + 1 -> stringRes(R.string.media_compression_quality_medium) + 2 -> stringRes(R.string.media_compression_quality_high) + 3 -> stringRes(R.string.media_compression_quality_uncompressed) + else -> stringRes(R.string.media_compression_quality_medium) + }, + modifier = Modifier.align(Alignment.Center), + ) + } + + Slider( + value = mediaQualitySlider.toFloat(), + onValueChange = { mediaQualitySlider = it.toInt() }, + valueRange = 0f..3f, + steps = 2, ) } - - Slider( - value = mediaQualitySlider.toFloat(), - onValueChange = { mediaQualitySlider = it.toInt() }, - valueRange = 0f..3f, - steps = 2, - ) } if (uris.first().media.isVideo() == true) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index a7703f2ed..37c1ad128 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -21,12 +21,10 @@ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -42,11 +40,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription -import com.vitorpamplona.amethyst.ui.note.AboutDisplay +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size55dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -55,7 +55,8 @@ fun ShowUserSuggestionList( userSuggestions: UserSuggestionState, onSelect: (User) -> Unit, accountViewModel: AccountViewModel, - modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), + modifier: Modifier = Modifier, + onEmpty: @Composable () -> Unit = {}, ) { UserSearchDataSourceSubscription(userSuggestions, accountViewModel) @@ -76,7 +77,7 @@ fun ShowUserSuggestionList( } } - WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier) + WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty) } @Composable @@ -97,7 +98,8 @@ fun WatchResponses( listState: LazyListState, onSelect: (User) -> Unit, accountViewModel: AccountViewModel, - modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), + modifier: Modifier = Modifier, + onEmpty: @Composable () -> Unit = {}, ) { val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) @@ -114,6 +116,8 @@ fun WatchResponses( ) } } + } else { + onEmpty() } } @@ -123,31 +127,21 @@ fun UserLine( accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - bottom = 10.dp, - ), - ) { - ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) - - Column( - modifier = Modifier.padding(start = 10.dp).weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay( - baseUser, - accountViewModel = accountViewModel, - ) + SlimListItem( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + leadingContent = { + ClickableUserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, onClick = null) + }, + headlineContent = { + UsernameDisplay(baseUser, accountViewModel = accountViewModel) + }, + supportingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + WatchAndDisplayNip05Row(baseUser, accountViewModel) } - - AboutDisplay(baseUser, accountViewModel) - } - } + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index bf0ad4223..4fc579daa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -27,6 +27,17 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.startsWithAny import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -38,12 +49,20 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +val userUriPrefixes = + listOf( + DualCase("npub"), + DualCase("nprofile"), + DualCase("nostr:npub"), + DualCase("nostr:nprofile"), + ) + @Stable class UserSuggestionState( val account: Account, - val requireAtSymbol: Boolean = true, + val nip05Client: INip05Client, ) { - val invalidations = MutableStateFlow(0) + val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") val searchDataSourceState = SearchQueryState(MutableStateFlow(""), account) @@ -55,9 +74,72 @@ class UserSuggestionState( .map(::userSearchTermOrNull) .onEach(::updateDataSource) + @OptIn(FlowPreview::class) + val nip05ResolutionFlow = + currentWord + .debounce(300) + .distinctUntilChanged() + .map(::userSearchTermOrNull) + .map { prefix -> + if (prefix != null) { + if (prefix.contains('@')) { + runCatching { + Nip05Id.parse(prefix)?.let { nip05 -> + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) + } + } + } + user + } + } + }.getOrNull() + } else if (prefix.startsWithAny(userUriPrefixes)) { + runCatching { + Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed -> + when (parsed) { + is NSec -> { + account.cache.getOrCreateUser(parsed.toPubKey().toHexKey()) + } + + is NPub -> { + account.cache.getOrCreateUser(parsed.hex) + } + + is NProfile -> { + val user = account.cache.getOrCreateUser(parsed.hex) + parsed.relay.forEach { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) + } + user + } + + else -> { + null + } + } + } + }.getOrNull() + } else if (prefix.length == 64 && Hex.isHex64(prefix)) { + account.cache.getOrCreateUser(prefix) + } else { + null + } + } else { + null + } + }.flowOn(Dispatchers.IO) + @OptIn(FlowPreview::class) val results = - combine(searchTerm, invalidations.debounce(100)) { prefix, version -> + combine(searchTerm, nip05ResolutionFlow, invalidations.debounce(100)) { prefix, nip05, version -> + if (nip05 != null) { + return@combine listOf(nip05) + } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { account.cache.findUsersStartingWith(prefix, account) @@ -68,7 +150,9 @@ class UserSuggestionState( }.flowOn(Dispatchers.IO) fun reset() { - currentWord.tryEmit("") + if (!currentWord.value.isEmpty()) { + currentWord.tryEmit("") + } } fun processCurrentWord(word: String) { @@ -81,18 +165,10 @@ class UserSuggestionState( } fun userSearchTermOrNull(currentWord: String): String? = - if (requireAtSymbol) { - if (currentWord.startsWith("@") && currentWord.length > 2) { - currentWord.removePrefix("@") - } else { - null - } + if (currentWord.length > 2) { + currentWord.removePrefix("@") } else { - if (currentWord.length > 1) { - currentWord - } else { - null - } + null } fun updateDataSource(searchTerm: String?) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt index 9ef050b0e..f40ab6d72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/DisplayZapSplits.kt @@ -51,7 +51,7 @@ fun DisplayZapSplits( remember(noteEvent) { val list = noteEvent.zapSplitSetup() if (list.isEmpty() && useAuthorIfEmpty) { - listOf( + listOf( ZapSplitSetup( pubKeyHex = noteEvent.pubKey, relay = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index a25ef4f8a..54cde53ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -116,13 +116,13 @@ private fun RenderPledgeAmount( ) { val repliesState by observeNoteReplies(baseNote, accountViewModel) var reward by remember { - mutableStateOf( + mutableStateOf( showAmount(baseReward.amount), ) } var hasPledge by remember { - mutableStateOf( + mutableStateOf( false, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index 2038a77b7..c63cd5a5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -58,7 +58,7 @@ fun DisplayUncitedHashtags( nav: INav, ) { val unusedHashtags by - produceState(initialValue = emptyList()) { + produceState(initialValue = emptyList()) { val tagsInEvent = event.hashtags() if (tagsInEvent.isNotEmpty()) { val state = CachedRichTextParser.parseText(content, event.tags.toImmutableListOfLists(), callbackUri) @@ -66,15 +66,15 @@ fun DisplayUncitedHashtags( val tagsInContent = state .paragraphs - .map { - it.words.mapNotNull { + .flatMap { paragraphState -> + paragraphState.words.mapNotNull { if (it is HashTagSegment) { it.hashtag } else { null } } - }.flatten() + } val unusedHashtags = tagsInEvent.filterNot { eventTag -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index e790bcf0a..a885e3ce1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits @@ -166,6 +167,7 @@ open class CommentPostViewModel : // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") // GeoHash var wantsToAddGeoHash by mutableStateOf(false) @@ -189,7 +191,7 @@ open class CommentPostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -270,6 +272,7 @@ open class CommentPostViewModel : wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -359,7 +362,7 @@ open class CommentPostViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null - val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val replyingTo = replyingTo val replyingToEvent = replyingTo?.event @@ -474,7 +477,7 @@ open class CommentPostViewModel : myMultiOrchestrator.upload( alt, contentWarningReason, - MediaCompressor.Companion.intToCompressorQuality(mediaQuality), + MediaCompressor.intToCompressorQuality(mediaQuality), server, account, context, @@ -555,6 +558,7 @@ open class CommentPostViewModel : wantsForwardZapTo = false wantsToMarkAsSensitive = false + contentWarningDescription = "" wantsToAddGeoHash = false wantsSecretEmoji = false @@ -585,9 +589,13 @@ open class CommentPostViewModel : if (message.selection.collapsed) { val lastWord = message.currentWord() - - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } emojiSuggestions?.processCurrentWord(lastWord) } @@ -703,5 +711,5 @@ open class CommentPostViewModel : return location!! } - override fun locationManager(): LocationState = Amethyst.Companion.instance.locationManager + override fun locationManager(): LocationState = Amethyst.instance.locationManager } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index a3ab72916..4eae653ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -49,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.replyModifier import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -254,7 +255,10 @@ private fun GenericCommentPostBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { - ContentSensitivityExplainer() + ContentSensitivityExplainer( + description = postViewModel.contentWarningDescription, + onDescriptionChange = { postViewModel.contentWarningDescription = it }, + ) } } @@ -346,7 +350,7 @@ private fun GenericCommentPostBody( it, postViewModel::autocompleteWithUser, accountViewModel, - modifier = Modifier.heightIn(0.dp, 300.dp), + modifier = SuggestionListDefaultHeightPage, ) } @@ -355,7 +359,7 @@ private fun GenericCommentPostBody( it, postViewModel::autocompleteWithEmoji, postViewModel::autocompleteWithEmojiUrl, - modifier = Modifier.heightIn(0.dp, 300.dp), + modifier = SuggestionListDefaultHeightPage, ) } @@ -382,6 +386,14 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { postViewModel.selectImage(it) } + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + TakePictureButton( onPictureTaken = { postViewModel.selectImage(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarEvent.kt new file mode 100644 index 000000000..2cd1bb6d9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarEvent.kt @@ -0,0 +1,322 @@ +/* + * 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.note.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date + +@Composable +fun RenderCalendarTimeSlotEvent( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? CalendarTimeSlotEvent ?: return + + val title = noteEvent.title() + val image = noteEvent.image() + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val location = noteEvent.location() + val dateRange = + remember(noteEvent) { + val start = noteEvent.start() + val end = noteEvent.end() + formatTimestampRange(start, end) + } + + CalendarHeader( + title = title, + image = image, + summary = summary, + location = location, + dateRange = dateRange, + note = note, + accountViewModel = accountViewModel, + ) +} + +@Composable +fun RenderCalendarDateSlotEvent( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? CalendarDateSlotEvent ?: return + + val title = noteEvent.title() + val image = noteEvent.image() + val summary = + remember(noteEvent) { + noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } + } + val location = noteEvent.location() + val dateRange = + remember(noteEvent) { + val start = noteEvent.start() + val end = noteEvent.end() + formatDateRange(start, end) + } + + CalendarHeader( + title = title, + image = image, + summary = summary, + location = location, + dateRange = dateRange, + note = note, + accountViewModel = accountViewModel, + ) +} + +@Composable +private fun CalendarHeader( + title: String?, + image: String?, + summary: String?, + location: String?, + dateRange: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + Column(MaterialTheme.colorScheme.replyModifier) { + image?.let { + MyAsyncImage( + imageUrl = it, + contentDescription = stringRes(R.string.preview_card_image_for, it), + contentScale = ContentScale.FillWidth, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) }, + onError = { DefaultImageHeader(note, accountViewModel) }, + ) + } ?: run { + DefaultImageHeader(note, accountViewModel, Modifier.fillMaxWidth()) + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + dateRange?.let { + Row( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 4.dp), + ) { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + location?.let { + Row( + modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 2.dp), + ) { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + summary?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + if (summary == null) { + Spacer(modifier = StdVertSpacer) + } + } +} + +private fun formatTimestampRange( + start: Long?, + end: Long?, +): String? { + if (start == null) return null + val formatter = SimpleDateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + val startStr = formatter.format(Date(start * 1000)) + return if (end != null && end != start) { + val endFormatter = SimpleDateFormat.getTimeInstance(DateFormat.SHORT) + "$startStr – ${endFormatter.format(Date(end * 1000))}" + } else { + startStr + } +} + +private fun formatDateRange( + start: String?, + end: String?, +): String? { + if (start == null) return null + return if (end != null && end != start) { + "$start – $end" + } else { + start + } +} + +@Composable +@Preview +fun RenderCalendarTimeSlotEventPreview() { + val event = + CalendarTimeSlotEvent( + id = "0c22faa881c52f32754e6f2f7188a1333388acf4bfb32ecc04edb48f3cf4fd5d", + pubKey = "c07b08396b7bffdb659f862dd7ead57ae169caea65ed573d161e13c1cd6d490c", + createdAt = 1773145949, + tags = + arrayOf( + arrayOf("d", "2cc67b34-f8c9-4bb2-aa80-df38d2c16903"), + arrayOf("t", "Meetup"), + arrayOf("r", ""), + arrayOf("title", "Bodø Bitcoin Meet -up!"), + arrayOf("image", "https://cdn.satlantis.io/1cpasswtt00lakevlscka06k40tsknjh2vhk4w0gkrcfurntdfyxqu6qges-1769516859659-ChatGPT%20Image%2027.%20jan.%202026%2C%2013_27_28.png"), + arrayOf("start", "1775671200"), + arrayOf("end", "1775674800"), + arrayOf("start_tzid", "Europe/Oslo"), + arrayOf("g", ""), + arrayOf("summary", "Bitcoin is for everyone — whether you’re just curious, stacking sats for the first time, or you’ve been around since the early days.\n\nThis meetup is an open and informal gathering for anyone interested in Bitcoin. There’s no strict agenda, no pressure, and no expectations. Just good conversations, shared curiosity, and a chance to meet like-minded people in Bodø.\n\nWho is this for?\n\nNew to Bitcoin and want to learn the basics\n\nLong-time Bitcoiners (OGs very welcome)\n\nBuilders, investors, students, professionals, skeptics, and the simply curious\n\nAnyone who wants to understand why Bitcoin matters\n\nWe’ll talk Bitcoin at whatever level feels natural — from fundamentals and real-world use cases to philosophy, technology, and current developments.\n\nDuring the meetup, I’ll also announce and briefly share details about the upcoming Bitcoin conference in Bodø on 14 August, bringing international and Nordic voices together around Bitcoin.\n\n📍 Location: Bodø, Bjørk\n🕒 Time: 20.00\n🍻 Format: Casual, social, open discussion\n\nCome as you are. Bring questions, ideas, or just yourself.\nEveryone is welcome."), + arrayOf("url", ""), + arrayOf("cohosts", "172100"), + arrayOf("calendar_event_tags", "education, meet-up, bitcoin, networking, fun"), + arrayOf("website", ""), + arrayOf("autoFollowHosts", "false"), + arrayOf("rsvp_gated_enabled", "false"), + arrayOf("allowCohostsToDownloadAttendeeList", "false"), + arrayOf("venue", "167911"), + arrayOf("location", "Storgata 8, 8006 Bodø, Norway"), + arrayOf("googleMapsUri", "https://maps.google.com/?cid=601501172909304252&g_mp=CiVnb29nbGUubWFwcy5wbGFjZXMudjEuUGxhY2VzLkdldFBsYWNlEAIYBCAA"), + arrayOf("venue_name", "Restaurant Bjørk"), + arrayOf("google_place_id", "ChIJYd5l1F0Q30URvGk9PyH2WAg"), + arrayOf("rsvp_waitlist_enabled", "false"), + ), + content = "Bitcoin is for everyone — whether you’re just curious, stacking sats for the first time, or you’ve been around since the early days.\n\nThis meetup is an open and informal gathering for anyone interested in Bitcoin. There’s no strict agenda, no pressure, and no expectations. Just good conversations, shared curiosity, and a chance to meet like-minded people in Bodø.\n\nWho is this for?\n\nNew to Bitcoin and want to learn the basics\n\nLong-time Bitcoiners (OGs very welcome)\n\nBuilders, investors, students, professionals, skeptics, and the simply curious\n\nAnyone who wants to understand why Bitcoin matters\n\nWe’ll talk Bitcoin at whatever level feels natural — from fundamentals and real-world use cases to philosophy, technology, and current developments.\n\nDuring the meetup, I’ll also announce and briefly share details about the upcoming Bitcoin conference in Bodø on 14 August, bringing international and Nordic voices together around Bitcoin.\n\n📍 Location: Bodø, Bjørk\n🕒 Time: 20.00\n🍻 Format: Casual, social, open discussion\n\nCome as you are. Bring questions, ideas, or just yourself.\nEveryone is welcome.", + sig = "a63bdfc5d33432ef6a1454c7b395586430f5602b822ccd07b9d1ccd9608ecda8508b58374bff28ab87d9b9c92c9cd5a7e95bfe67587ccc852e3fea78c0c682c3", + ) + + LocalCache.justConsume(event, null, true) + val note = LocalCache.getOrCreateNote(event.id) + + ThemeComparisonColumn { + RenderCalendarTimeSlotEvent( + note, + mockAccountViewModel(), + EmptyNav(), + ) + } +} + +@Composable +@Preview +fun RenderCalendarDateSlotEventPreview() { + val event = + CalendarDateSlotEvent( + id = "d8d76e5a7786210684317fe0264cc44fdc8147254456c20a9fb99db1c090a37f", + pubKey = "068598899f82af67fc67cfcc44457ed9a8cc686b7d63ab9db969c96c3fe9bffd", + createdAt = 1771952727, + content = "Join the OER Community #qualität #communities #oer #oep", + sig = "6122acb989dbe24780c463d3026c255d0a223221e96ea29331c68b7442a3710460d6886705fe8f9b4fb0c5565f7e6ed9cb5d9b254e5b656926beb56a0d83da47", + tags = + arrayOf( + arrayOf("d", "event-1771952727237-oue85wf9r"), + arrayOf("h", "d6d214fe27fcc0a691dd0f04d152b7cdda7f61f96f26dc421df46af0bb51792e"), + arrayOf("title", "FOERBICO Tagung"), + arrayOf("start", "1771891200"), + arrayOf("end", "1771977600"), + ), + ) + + LocalCache.justConsume(event, null, true) + val note = LocalCache.getOrCreateNote(event.id) + + ThemeComparisonColumn { + RenderCalendarDateSlotEvent( + note, + mockAccountViewModel(), + EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CodeSnippet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CodeSnippet.kt new file mode 100644 index 000000000..2ae040eb9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CodeSnippet.kt @@ -0,0 +1,242 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent + +@Composable +fun RenderCodeSnippetEvent(baseNote: Note) { + val noteEvent = baseNote.event as? CodeSnippetEvent ?: return + + val name = + remember(noteEvent) { + noteEvent.snippetName() + ?: noteEvent.language()?.let { "$it snippet" } + ?: "Code Snippet" + } + val language = remember(noteEvent) { noteEvent.language() ?: noteEvent.extension() } + val description = remember(noteEvent) { noteEvent.snippetDescription() } + val codePreview = + remember(noteEvent) { + noteEvent.content + .lines() + .take(5) + .joinToString("\n") + } + + Column(MaterialTheme.colorScheme.replyModifier) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + language?.let { + Spacer(modifier = StdHorzSpacer) + Box( + modifier = + Modifier + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + description?.ifBlank { null }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdVertSpacer) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp) + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), QuoteBorder) + .padding(10.dp), + ) { + Text( + text = codePreview, + style = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ), + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun RenderCodeSnippetHeaderForThread(noteEvent: CodeSnippetEvent) { + val name = + remember(noteEvent) { + noteEvent.snippetName() + ?: noteEvent.language()?.let { "$it snippet" } + ?: "Code Snippet" + } + val language = remember(noteEvent) { noteEvent.language() ?: noteEvent.extension() } + val description = remember(noteEvent) { noteEvent.snippetDescription() } + val license = remember(noteEvent) { noteEvent.license() } + val runtime = remember(noteEvent) { noteEvent.runtime() } + val deps = remember(noteEvent) { noteEvent.deps() } + + Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = name, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + language?.let { + Spacer(modifier = StdHorzSpacer) + Box( + modifier = + Modifier + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + description?.ifBlank { null }?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = StdVertSpacer) + + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), QuoteBorder) + .padding(12.dp), + ) { + Text( + text = noteEvent.content, + style = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ), + ) + } + + license?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = "License: $it", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + runtime?.let { + Text( + text = "Runtime: $it", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (deps.isNotEmpty()) { + Text( + text = "Deps: ${deps.joinToString(", ")}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index cbc301de9..1f8b59cff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis @Composable -public fun RenderEmojiPack( +fun RenderEmojiPack( baseNote: Note, actionable: Boolean, backgroundColor: MutableState, @@ -86,7 +86,7 @@ public fun RenderEmojiPack( @OptIn(ExperimentalLayoutApi::class) @Composable -public fun RenderEmojiPack( +fun RenderEmojiPack( noteEvent: EmojiPackEvent, baseNote: Note, actionable: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index e972fc750..949b92473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -649,12 +649,12 @@ fun RenderPollGameResultsPreview() { Column(Modifier.padding(10.dp)) { RenderPoll( note, - false, - true, - 2, - remember { mutableStateOf(Color.Transparent) }, - mockAccountViewModel(), - EmptyNav(), + makeItShort = false, + canPreview = true, + quotesLeft = 2, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), ) } } @@ -758,12 +758,12 @@ fun RenderPollColorResultsPreview() { Column(Modifier.padding(10.dp)) { RenderPoll( note, - false, - true, - 2, - remember { mutableStateOf(Color.Transparent) }, - mockAccountViewModel(), - EmptyNav(), + makeItShort = false, + canPreview = true, + quotesLeft = 2, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt index 2e178a678..a2b8773ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicMessage.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -102,7 +101,7 @@ fun DisplayUncitedUsers( nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") - val uncitedUsers by produceState(initialValue = emptyList()) { + val uncitedUsers by produceState(initialValue = emptyList()) { val users = event.groupKeySetWithoutOwner() - event.citedUsers() if (users.isNotEmpty()) { val newUsers = accountViewModel.loadUsersSync(users.toList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index 58e0e03f7..30f5f002b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -235,14 +235,14 @@ fun DisplayTrustedRelayList( } @Composable -fun DisplayFavoriteRelayList( +fun DisplayRelayFeedsList( baseNote: Note, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { - val relays by accountViewModel.account.favoriteRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( - accountViewModel.account.favoriteRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + val relays by accountViewModel.account.relayFeedsListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.relayFeedsListDecryptionCache.fastStartValueForRelayList(baseNote), ) DisplayRelaySet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index 8b063890e..7c5442ff0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -139,12 +139,12 @@ fun TorrentCommentPreview() { toPreview = { RenderTorrentComment( comment, - false, - true, - 3, - ReplyRenderType.FULL, - remember { mutableStateOf(Color.Transparent) }, - EmptyState, + makeItShort = false, + canPreview = true, + quotesLeft = 3, + unPackReply = ReplyRenderType.FULL, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + editState = EmptyState, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 4df5ab134..ecae95ec2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -55,9 +55,17 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) { animationSpec = tween(durationMillis = 100), ) { state -> when (state) { - is AccountState.Loading -> LoadingSetup() - is AccountState.LoggedOff -> LoggedOffSetup(accountSessionManager) - is AccountState.LoggedIn -> LoggedInSetup(state, accountSessionManager) + is AccountState.Loading -> { + LoadingSetup() + } + + is AccountState.LoggedOff -> { + LoggedOffSetup(accountSessionManager) + } + + is AccountState.LoggedIn -> { + LoggedInSetup(state, accountSessionManager) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 3f5618ae1..3c8dd191e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultDMRelayList +import com.vitorpamplona.amethyst.model.DefaultGlobalRelays import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.DefaultNIP65List import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet @@ -60,6 +61,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -118,7 +120,7 @@ class AccountSessionManager( } } - private suspend fun requestLoginUI() = _accountContent.update { AccountState.LoggedOff } + private fun requestLoginUI() = _accountContent.update { AccountState.LoggedOff } suspend fun loginAndStartUI( key: String, @@ -276,7 +278,7 @@ class AccountSessionManager( localPreferences.setDefaultAccount(accountSettings) - startUI(accountSettings) + startUI(accountSettings, route = Route.ImportFollowsSelectUser) scope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. @@ -289,6 +291,7 @@ class AccountSessionManager( accountSettings.backupDMRelayList?.let { client.send(it, toPost) } accountSettings.backupSearchRelayList?.let { client.send(it, toPost) } accountSettings.backupIndexRelayList?.let { client.send(it, toPost) } + accountSettings.backupRelayFeedsList?.let { client.send(it, toPost) } } } } @@ -312,6 +315,7 @@ class AccountSessionManager( backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), backupIndexRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), tempSigner), backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner), + backupRelayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, tempSigner), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt index 0495229ae..0b764e9e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountState.kt @@ -65,7 +65,9 @@ class CompositionObserver( private val vmStore: StoreOwnerRegistry, private val key: Any, ) : RememberObserver { - override fun onRemembered() {} + override fun onRemembered() { + // No action needed when remembered — registration happens at construction time. + } override fun onForgotten() = vmStore.composableDetached(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 4721d9cef..13f27017a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -186,7 +186,7 @@ class TopNavFilterState( account.hashtagList.flow, account.geohashList.flow, account.communityList.flowNotes, - account.favoriteRelayList.flow, + account.relayFeedsList.flow, ::mergeInterests, ).onStart { emit( @@ -194,7 +194,7 @@ class TopNavFilterState( account.hashtagList.flow.value, account.geohashList.flow.value, account.communityList.flowNotes.value, - account.favoriteRelayList.flow.value, + account.relayFeedsList.flow.value, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1e0cea279..3ae5a63e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -103,6 +103,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip05DnsIdentifiers.EmptyNip05Client +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.nip18Reposts.GenericRepostEvent @@ -164,6 +167,7 @@ class AccountViewModel( val torSettings: TorSettingsFlow, val dataSources: RelaySubscriptionsCoordinator, val httpClientBuilder: IRoleBasedHttpClientBuilder, + val nip05Client: INip05Client, ) : ViewModel(), Dao { var firstRoute: Route? = null @@ -357,10 +361,10 @@ class AccountViewModel( return if (isPostHidden || isDecryptedPostHidden) { // Spam + Blocked Users + Hidden Words + Sensitive Content - NoteComposeReportState(isPostHidden, false, false, isHiddenAuthor) + NoteComposeReportState(isPostHidden, isAcceptable = false, canPreview = false, isHiddenAuthor = isHiddenAuthor) } else if (isFromLoggedIn || isFromLoggedInFollow) { // No need to process if from trusted people - NoteComposeReportState(isPostHidden, true, true, isHiddenAuthor) + NoteComposeReportState(isPostHidden, isAcceptable = true, canPreview = true, isHiddenAuthor = isHiddenAuthor) } else { val newCanPreview = !note.hasAnyReports() @@ -368,7 +372,7 @@ class AccountViewModel( if (newCanPreview && newIsAcceptable) { // No need to process reports if nothing is wrong - NoteComposeReportState(isPostHidden, true, true, false) + NoteComposeReportState(isPostHidden, isAcceptable = true, canPreview = true, isHiddenAuthor = false) } else { NoteComposeReportState( isPostHidden, @@ -511,7 +515,7 @@ class AccountViewModel( }.toMutableMap() val results = - mapNotNullAsync( + mapNotNullAsync( zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, ) { next -> val info = innerDecryptAmountMessage(next.request, next.response) @@ -914,6 +918,8 @@ class AccountViewModel( fun unfollow(channel: EphemeralChatChannel) = launchSigner { account.unfollow(channel) } + fun follow(users: List) = launchSigner { account.follow(users) } + fun follow(user: User) = launchSigner { account.follow(user) } fun unfollow(user: User) = launchSigner { account.unfollow(user) } @@ -926,9 +932,9 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } - fun followFavoriteRelay(url: NormalizedRelayUrl) = launchSigner { account.followFavoriteRelay(url) } + fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } - fun unfollowFavoriteRelay(url: NormalizedRelayUrl) = launchSigner { account.unfollowFavoriteRelay(url) } + fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } fun showWord(word: String) = launchSigner { account.showWord(word) } @@ -1099,7 +1105,7 @@ class AccountViewModel( } } - override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) + override fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address) fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) @@ -1210,6 +1216,7 @@ class AccountViewModel( val torSettings: TorSettingsFlow, val dataSources: RelaySubscriptionsCoordinator, val okHttpClient: RoleBasedHttpClientBuilder, + val nip05Client: Nip05Client, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = @@ -1219,6 +1226,7 @@ class AccountViewModel( torSettings, dataSources, okHttpClient, + nip05Client, ) as T } @@ -1609,7 +1617,7 @@ class AccountViewModel( }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), - emptySet(), + emptySet(), ) val draftNoteCache = CachedDraftNotes(this) @@ -1740,6 +1748,7 @@ fun mockAccountViewModel(): AccountViewModel { torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), + nip05Client = EmptyNip05Client(), ).also { mockedCache = it } @@ -1790,6 +1799,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, failureTracker, scope), + nip05Client = EmptyNip05Client(), ).also { vitorCache = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 655a60487..d1f44cea2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -29,6 +29,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.LifecycleResumeEffect @@ -71,6 +72,7 @@ fun LoggedInPage( torSettings = Amethyst.instance.torPrefs.value, dataSources = Amethyst.instance.sources, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder, + nip05Client = Amethyst.instance.nip05Client, ), ) @@ -97,12 +99,24 @@ fun LoggedInPage( // Register token with the Push Notification Provider. NotificationRegistration(accountViewModel) + WatchLocationPermissions() + AppNavigation( accountViewModel = accountViewModel, accountSessionManager = accountSessionManager, ) } +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun WatchLocationPermissions() { + val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + + LaunchedEffect(locationPermissionState.status.isGranted) { + Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted) + } +} + @Composable fun ObserveAntiSpamFilterSettings(accountViewModel: AccountViewModel) { val isSpamActive by accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 072bb05cb..4fa4ac913 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -173,7 +173,7 @@ fun ChatBubbleLayout( private fun BubblePreview() { val bgColor = remember { - mutableStateOf(Color.Transparent) + mutableStateOf(Color.Transparent) } Column { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index c717b982d..ac2453dec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -222,8 +222,8 @@ fun RenderRelayLinePreview() { "wss://nos.lol", "http://icon.com/icon.ico", Modifier, - true, - true, + showPicture = true, + loadRobohash = true, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index 1f7540e45..ec277266d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -35,7 +35,7 @@ fun filterNip04DMs( account: Account?, since: SincePerRelayMap?, ): List? { - if (group == null || group.isEmpty() || account == null) return null + if (group.isNullOrEmpty() || account == null) return null val userOutboxRelays = account.homeRelays.flow.value val userInboxRelays = account.dmRelays.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index 8b22d03ef..91addfd7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -73,14 +73,14 @@ fun NewChatroomSubjectDialog( Surface { val groupName = remember { - mutableStateOf( + mutableStateOf( accountViewModel.account.chatroomList.rooms .get(room) ?.subject ?.value ?: "", ) } - val message = remember { mutableStateOf("") } + val message = remember { mutableStateOf("") } val scope = rememberCoroutineScope() Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index aaa510974..61613f21f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip40Expiration.expiration @@ -160,6 +161,7 @@ class ChatNewMessageViewModel : // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") // GeoHash var wantsToAddGeoHash by mutableStateOf(false) @@ -188,7 +190,7 @@ class ChatNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -292,6 +294,7 @@ class ChatNewMessageViewModel : wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null @@ -440,7 +443,7 @@ class ChatNewMessageViewModel : val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val message = message.text - val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null @@ -538,6 +541,7 @@ class ChatNewMessageViewModel : wantsForwardZapTo = false wantsToMarkAsSensitive = false + contentWarningDescription = "" wantsToAddGeoHash = false wantsSecretEmoji = false @@ -565,10 +569,14 @@ class ChatNewMessageViewModel : urlPreviews.update(newMessage) if (message.selection.collapsed) { - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - - val lastWord = message.currentWord() - userSuggestions?.processCurrentWord(lastWord) + val lastWord = newMessage.currentWord() + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } emojiSuggestions?.processCurrentWord(lastWord) } @@ -596,7 +604,7 @@ class ChatNewMessageViewModel : toUsersTagger.run() val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } - if (users == null || users.isEmpty()) { + if (users.isNullOrEmpty()) { room = null updateNIP17StatusFromRoom() } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index f2cd40249..c10ac9f86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow @@ -82,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -121,6 +121,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -237,7 +238,10 @@ fun GroupDMScreenContent( DisplayPreviews(postViewModel, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { - ContentSensitivityExplainer() + ContentSensitivityExplainer( + description = postViewModel.contentWarningDescription, + onDescriptionChange = { postViewModel.contentWarningDescription = it }, + ) } if (postViewModel.wantsToAddGeoHash) { @@ -299,7 +303,7 @@ fun GroupDMScreenContent( it, postViewModel::autocompleteWithUser, accountViewModel, - Modifier.heightIn(0.dp, 300.dp), + SuggestionListDefaultHeightPage, ) } @@ -308,7 +312,7 @@ fun GroupDMScreenContent( it, postViewModel::autocompleteWithEmoji, postViewModel::autocompleteWithEmojiUrl, - Modifier.heightIn(0.dp, 300.dp), + SuggestionListDefaultHeightPage, ) } @@ -381,6 +385,14 @@ private fun BottomRowActions( ) { postViewModel.pickedMedia(it) } + + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.pickedMedia(it) + } } else { IconButton( onClick = { accountViewModel.toastManager.toast(R.string.messages_cant_upload_title, R.string.messages_cant_upload_explainer) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index c80713383..db5219700 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -57,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.PostKeyboard +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -119,6 +121,7 @@ fun PrivateMessageEditFieldRow( it, channelScreenModel::autocompleteWithUser, accountViewModel, + SuggestionListDefaultHeightChat, ) } @@ -127,6 +130,7 @@ fun PrivateMessageEditFieldRow( it, channelScreenModel::autocompleteWithEmoji, channelScreenModel::autocompleteWithEmojiUrl, + SuggestionListDefaultHeightChat, ) } @@ -203,6 +207,14 @@ fun KeyboardLeadingIcon( onImageChosen = channelScreenModel::pickedMedia, ) + SelectFromFiles( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + channelScreenModel.pickedMedia(it) + } + ToggleNip17Button(channelScreenModel, accountViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt index c1561c440..1163f14ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterMessagesToPublicChat( channel: PublicChatChannel, since: SincePerRelayMap?, -): List? = +): List = channel.relays().toSet().map { RelayBasedFilter( relay = it, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt index 44bd9e3b4..006ca2832 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt @@ -31,7 +31,7 @@ fun filterMyMessagesToLiveActivities( channel: LiveActivitiesChannel, pubKey: HexKey, since: SincePerRelayMap?, -): List? = +): List = channel.relays().toSet().map { RelayBasedFilter( relay = it, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index c6e22a2e7..7d3b4d890 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery @@ -222,13 +223,18 @@ private fun ChannelMetadataScaffold( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { - RelayUrlEditField { postViewModel.addHomeRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addHomeRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) Spacer(modifier = DoubleVertSpacer) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 59c6800e7..310d5044a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -129,7 +129,7 @@ class ChannelMetadataViewModel : ViewModel() { val template = if (event != null) { - val hint = EventHintBundle(event, channel.relays().firstOrNull()) + val hint = EventHintBundle(event, channel.relays().firstOrNull()) ChannelMetadataEvent.build( channelName.value.text, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index a38d97ad9..9014fb1ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -81,6 +81,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -145,6 +147,7 @@ open class ChannelNewMessageViewModel : // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") // GeoHash var wantsToAddGeoHash by mutableStateOf(false) @@ -168,7 +171,7 @@ open class ChannelNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -224,6 +227,7 @@ open class ChannelNewMessageViewModel : wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null @@ -383,6 +387,8 @@ open class ChannelNewMessageViewModel : val channelRelays = channel.relays() val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null + return if (channel is PublicChatChannel) { val replyingToEvent = replyTo.value?.toEventHint() val channelEvent = channel.event @@ -394,6 +400,7 @@ open class ChannelNewMessageViewModel : hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } geoHash?.let { geohash(it) } @@ -406,6 +413,7 @@ open class ChannelNewMessageViewModel : hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } geoHash?.let { geohash(it) } @@ -417,6 +425,7 @@ open class ChannelNewMessageViewModel : hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } geoHash?.let { geohash(it) } @@ -435,6 +444,7 @@ open class ChannelNewMessageViewModel : hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } emojis(emojis) imetas(usedAttachments) @@ -446,6 +456,7 @@ open class ChannelNewMessageViewModel : hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } emojis(emojis) imetas(usedAttachments) @@ -455,6 +466,7 @@ open class ChannelNewMessageViewModel : hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } emojis(emojis) imetas(usedAttachments) @@ -469,6 +481,7 @@ open class ChannelNewMessageViewModel : hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } emojis(emojis) imetas(usedAttachments) @@ -503,7 +516,7 @@ open class ChannelNewMessageViewModel : wantsForwardZapTo = false wantsToMarkAsSensitive = false - + contentWarningDescription = "" wantsToAddGeoHash = false forwardZapTo = SplitBuilder() @@ -517,7 +530,7 @@ open class ChannelNewMessageViewModel : emojiSuggestions?.reset() } - open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull() + open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text).withScheme.firstOrNull() open fun addToMessage(it: String) { updateMessage(TextFieldValue(message.text + " " + it)) @@ -529,9 +542,13 @@ open class ChannelNewMessageViewModel : if (newMessage.selection.collapsed) { val lastWord = newMessage.currentWord() - - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } emojiSuggestions?.processCurrentWord(lastWord) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 0f82b1e40..1e66b8f75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.FlowPreview @@ -96,6 +97,7 @@ fun EditFieldRow( it, channelScreenModel::autocompleteWithUser, accountViewModel, + SuggestionListDefaultHeightChat, ) } @@ -104,6 +106,7 @@ fun EditFieldRow( it, channelScreenModel::autocompleteWithEmoji, channelScreenModel::autocompleteWithEmojiUrl, + SuggestionListDefaultHeightChat, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt index e333f7894..ad6ca0ddb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt @@ -40,7 +40,7 @@ class FollowingEphemeralChatSubAssembler( override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, - ): List? = + ): List = listOfNotNull( filterFollowingEphemeralChats(key.account.ephemeralChatList.liveEphemeralChatList.value, since), ).flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt index 8e4b0e4a8..98c66b737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt @@ -40,7 +40,7 @@ class FollowingPublicChatSubAssembler( override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, - ): List? = + ): List = listOfNotNull( filterLastMessageFollowingPublicChats(key.account.publicChatList.flowSet.value, since), filterFollowingPublicChatsCreationEvent(key.account.publicChatList.flowSet.value, since), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index de7626e47..f1ce97525 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.TextSpinner @@ -196,7 +197,8 @@ private fun ImageVideoPostChat( fileServers .firstOrNull { it == accountViewModel.account.settings.defaultFileServer } ?.name - ?: fileServers[0].name, + ?: fileServers.firstOrNull()?.name + ?: DEFAULT_MEDIA_SERVERS[0].name, options = fileServerOptions, onSelect = { fileUploadState.selectedServer = fileServers[it] }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 3b669b127..ad9ad0e22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt index 1c1b7fb6d..d1a7d0e55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt @@ -42,7 +42,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 68ad9f137..883366290 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -117,8 +117,7 @@ open class DiscoverLiveFeedFilter( fun convertStatusToOrder(event: LiveActivitiesEvent?): Int { if (event == null) return 0 - val url = event.streaming() - if (url == null) return 0 + val url = event.streaming() ?: return 0 return when (event.status()) { StatusTag.STATUS.LIVE -> { if (OnlineChecker.isCachedAndOffline(url)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt index 41fffe055..3c89d5e9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt @@ -238,7 +238,7 @@ fun LoadParticipants( } } - val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) + val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt index a45973b3b..f5b4a505b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -34,7 +34,7 @@ fun filterCommunitiesByHashtag( hashtags: Set?, since: Long?, ): List { - if (hashtags == null || hashtags.isEmpty()) return emptyList() + if (hashtags.isNullOrEmpty()) return emptyList() val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index f75e7d737..6fa904415 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -189,7 +189,7 @@ fun RenderContentDVMThumb( card.personalized?.let { var color = Color.DarkGray var name = "generic" - if (card.personalized == true) { + if (card.personalized) { color = MaterialTheme.colorScheme.bitcoinColor name = "Personalized" } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index cac04a869..5f781ace3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -48,6 +47,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -222,7 +223,10 @@ private fun NewProductBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { - ContentSensitivityExplainer() + ContentSensitivityExplainer( + description = postViewModel.contentWarningDescription, + onDescriptionChange = { postViewModel.contentWarningDescription = it }, + ) } } @@ -314,7 +318,7 @@ private fun NewProductBody( it, postViewModel::autocompleteWithUser, accountViewModel, - modifier = Modifier.heightIn(0.dp, 300.dp), + modifier = SuggestionListDefaultHeightPage, ) } @@ -323,7 +327,7 @@ private fun NewProductBody( it, postViewModel::autocompleteWithEmoji, postViewModel::autocompleteWithEmojiUrl, - modifier = Modifier.heightIn(0.dp, 300.dp), + modifier = SuggestionListDefaultHeightPage, ) } @@ -350,6 +354,14 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) { postViewModel.selectImage(it) } + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + TakePictureButton( onPictureTaken = { postViewModel.selectImage(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 14a3752a3..fc95787d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits @@ -142,7 +143,7 @@ open class NewProductViewModel : var price by mutableStateOf(TextFieldValue("")) var locationText by mutableStateOf(TextFieldValue("")) var category by mutableStateOf(TextFieldValue("")) - var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) + var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) // Invoices var canAddInvoice by mutableStateOf(false) @@ -157,6 +158,7 @@ open class NewProductViewModel : // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") // GeoHash var wantsToAddGeoHash by mutableStateOf(false) @@ -182,7 +184,7 @@ open class NewProductViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -250,6 +252,7 @@ open class NewProductViewModel : wantsForwardZapTo = localfowardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null @@ -329,7 +332,7 @@ open class NewProductViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null - val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val quotes = findNostrUris(tagger.message) @@ -450,6 +453,7 @@ open class NewProductViewModel : wantsForwardZapTo = false wantsToMarkAsSensitive = false + contentWarningDescription = "" wantsToAddGeoHash = false wantsSecretEmoji = false @@ -485,9 +489,13 @@ open class NewProductViewModel : if (message.selection.collapsed) { val lastWord = message.currentWord() - - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } emojiSuggestions?.processCurrentWord(lastWord) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt index bc425ba1b..bf227d5e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -33,7 +33,7 @@ fun filterClassifiedsByHashtag( hashtags: Set?, since: Long?, ): List? { - if (hashtags == null || hashtags.isEmpty()) return null + if (hashtags.isNullOrEmpty()) return null val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 53ef624e4..b00fbc9a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -57,7 +57,7 @@ class GeoHashFeedFilter( override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) - private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it, tag) } + private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it, tag) } fun acceptableEvent( it: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt index a6f174ab0..9d7fe94ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt @@ -32,7 +32,7 @@ class GeoHashFeedFilterSubAssembler( override fun updateFilter( key: GeohashQueryState, since: SincePerRelayMap?, - ): List? = filterPostsByGeohash(key.geohash, key.relays, since) + ): List = filterPostsByGeohash(key.geohash, key.relays, since) /** * Only one key per hashtag. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index fbb048075..9a95a0e46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -59,7 +59,7 @@ class HashtagFeedFilter( override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) - private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it, tag) } + private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it, tag) } fun acceptableEvent( it: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index d03df69d3..99f8a6e98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -64,6 +63,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.replyModifier import kotlinx.collections.immutable.persistentListOf @@ -303,7 +304,10 @@ private fun NewPostScreenBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { - ContentSensitivityExplainer() + ContentSensitivityExplainer( + description = postViewModel.contentWarningDescription, + onDescriptionChange = { postViewModel.contentWarningDescription = it }, + ) } } @@ -449,7 +453,7 @@ private fun NewPostScreenBody( it, postViewModel::autocompleteWithUser, accountViewModel, - modifier = Modifier.heightIn(0.dp, 300.dp), + modifier = SuggestionListDefaultHeightPage, ) } @@ -458,7 +462,7 @@ private fun NewPostScreenBody( it, postViewModel::autocompleteWithEmoji, postViewModel::autocompleteWithEmojiUrl, - modifier = Modifier.heightIn(0.dp, 300.dp), + modifier = SuggestionListDefaultHeightPage, ) } @@ -485,6 +489,14 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.selectImage(it) } + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + TakePictureButton( onPictureTaken = { postViewModel.selectImage(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index c45eb4b20..5eb51fd1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.content.Context import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -93,6 +94,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -220,7 +222,7 @@ open class ShortNotePostViewModel : var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) var pollOptions: SnapshotStateMap = newStateMapPollOptions() - var closedAt by mutableStateOf(TimeUtils.oneDayAhead()) + var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead()) // Invoices var canAddInvoice by mutableStateOf(false) @@ -235,6 +237,7 @@ open class ShortNotePostViewModel : // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") // GeoHash var wantsToAddGeoHash by mutableStateOf(false) @@ -259,7 +262,7 @@ open class ShortNotePostViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -422,6 +425,7 @@ open class ShortNotePostViewModel : wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null @@ -501,6 +505,7 @@ open class ShortNotePostViewModel : wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null @@ -554,8 +559,8 @@ open class ShortNotePostViewModel : val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer uploadVoiceMessageSync( serverToUse, - { _, _ -> }, // Error handling is done by checking voiceMetadata below - ) + ) { _, _ -> } // Error handling is done by checking voiceMetadata below + // Abort if upload failed - don't post without voice data if (voiceMetadata == null) { Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post") @@ -684,7 +689,7 @@ open class ShortNotePostViewModel : val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null return if (wantsPoll) { val options = pollOptions.map { it.value } @@ -895,6 +900,7 @@ open class ShortNotePostViewModel : wantsForwardZapTo = false wantsToMarkAsSensitive = false + contentWarningDescription = "" wantsToAddGeoHash = false wantsExclusiveGeoPost = false wantsSecretEmoji = false @@ -929,10 +935,14 @@ open class ShortNotePostViewModel : urlPreviews.update(message) if (message.selection.collapsed) { - val lastWord = message.currentWord() - - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) + val lastWord = newMessage.currentWord() + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } emojiSuggestions?.processCurrentWord(lastWord) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 80f623b2c..2938ad1a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -160,10 +160,8 @@ class HomeLiveFilter( val noteEvent = note.event if (noteEvent is LiveActivitiesChatMessageEvent) { - val stream = noteEvent.activityAddress() - if (stream == null) return false - val streamChannel = LocalCache.getLiveActivityChannelIfExists(stream) - if (streamChannel == null) return false + val stream = noteEvent.activityAddress() ?: return false + val streamChannel = LocalCache.getLiveActivityChannelIfExists(stream) ?: return false if (streamChannel.info?.status() != StatusTag.STATUS.LIVE) return false } @@ -194,7 +192,7 @@ class HomeLiveFilter( return collection.sortedWith( compareByDescending { followCounts[it] } - .thenByDescending { it.lastNote?.createdAt() ?: 0L } + .thenByDescending { it.lastNote?.createdAt() ?: 0L } .thenBy { it.hashCode() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index 39dc82869..5c0c0602a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -108,7 +108,7 @@ fun PeopleListScreen( nav: INav, ) { val viewModel: PeopleListViewModel = viewModel() - viewModel.init(accountViewModel.account, selectedDTag) + viewModel.init(accountViewModel, selectedDTag) val pagerState = rememberPagerState { 2 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index d360ba0eb..03db90149 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -66,12 +67,12 @@ class PeopleListViewModel : ViewModel() { fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) fun init( - account: Account, + accountVM: AccountViewModel, selectedDTag: String, ) { - if (!this::account.isInitialized || this.account != account) { - this.account = account - this.userSuggestions = UserSuggestionState(account, false) + if (!this::account.isInitialized || this.account != accountVM.account) { + this.account = accountVM.account + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) } this.selectedDTag.tryEmit(selectedDTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt index b48e098a0..eefa908ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -99,7 +99,7 @@ fun FollowPackScreen( nav: INav, ) { val viewModel: FollowPackViewModel = viewModel() - viewModel.init(accountViewModel.account, selectedDTag) + viewModel.init(accountViewModel, selectedDTag) Scaffold( topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt index e110d9cc1..eab070de4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -66,12 +67,12 @@ class FollowPackViewModel : ViewModel() { fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) fun init( - account: Account, + accountVM: AccountViewModel, selectedDTag: String, ) { - if (!this::account.isInitialized || this.account != account) { - this.account = account - this.userSuggestions = UserSuggestionState(account, false) + if (!this::account.isInitialized || this.account != accountVM.account) { + this.account = accountVM.account + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) } this.selectedDTag.tryEmit(selectedDTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index 2ac13c5f7..369852526 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -426,7 +426,7 @@ private fun ListModifyDescriptionDialog( onDismissDialog: () -> Unit, onModifyDescription: (String) -> Unit, ) { - val updatedDescription = remember { mutableStateOf("") } + val updatedDescription = remember { mutableStateOf("") } val modifyIndicatorLabel = if (currentDescription == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt new file mode 100644 index 000000000..f2c663598 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt @@ -0,0 +1,332 @@ +/* + * 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.newUser + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine +import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun ImportFollowListPickFollowsScreen( + contactListNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopBarExtensibleWithBackButton( + title = {}, + popBack = nav::popBack, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + PickFollowsBody( + contactListNote = contactListNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun PickFollowsBody( + contactListNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) { + ImportHeader() + Spacer(Modifier.height(20.dp)) + + DisplayFollowList(contactListNote, Modifier.weight(1f), accountViewModel, nav) + } +} + +@Composable +fun DisplayFollowList( + contactListNote: AddressableNote, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + val contactsState by observeNoteEventAndMap?>(contactListNote, accountViewModel) { contactList -> + contactList + ?.unverifiedFollowKeySet() + ?.mapNotNull { + accountViewModel.checkGetOrCreateUser(it) + }?.toPersistentList() + } + + val contacts = contactsState + + if (contacts == null) { + LoadingIndicator(stringRes(R.string.fetching_follow_list), modifier, nav) + } else if (contacts.isEmpty()) { + ErrorMessage(stringRes(R.string.no_follows_found), modifier, nav) + } else { + PreviewList( + contacts, + modifier, + accountViewModel, + nav, + ) + } +} + +@Composable +private fun PreviewList( + contacts: ImmutableList, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + var selected by remember { mutableStateOf(setOf()) } + + Column(modifier) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringRes(R.string.accounts_found, contacts.size), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + stringRes(R.string.num_selected, selected.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + // Select all + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = selected.size == contacts.size, + onCheckedChange = { + selected = if (it) contacts.toSet() else setOf() + }, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.select_all), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + + HorizontalDivider() + + LazyColumn(contentPadding = PaddingValues(vertical = 4.dp), modifier = Modifier.weight(1f)) { + items(items = contacts, key = { it.pubkeyHex }) { entry -> + FollowEntryRow( + user = entry, + isSelected = entry in selected, + onToggle = { selected = if (entry in selected) selected - entry else selected + entry }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton( + onClick = { + accountViewModel.follow(selected.toList()) + nav.popUpTo(Route.Home, Route.Home::class) + }, + ) { + Text(stringRes(R.string.follow_accounts, selected.size)) + } + } + } +} + +@Composable +private fun LoadingIndicator( + message: String, + modifier: Modifier, + nav: INav, +) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(Modifier.size(40.dp), strokeWidth = 3.dp) + Spacer(Modifier.height(12.dp)) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { nav.popUpTo(Route.Home, Route.Home::class) }) { Text(stringRes(R.string.skip_for_now)) } + } + } +} + +@Composable +private fun ErrorMessage( + message: String, + modifier: Modifier, + nav: INav, +) { + Column(modifier) { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { nav.popUpTo(Route.Home, Route.Home::class) }) { Text(stringRes(R.string.skip_for_now)) } + } + } +} + +@Composable +private fun ImportHeader() { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + stringRes(R.string.select_users_to_follow), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + stringRes(R.string.start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun FollowEntryRow( + user: User, + isSelected: Boolean, + onToggle: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (isSelected) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + contentDescription = null, + tint = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + UserLine(user, accountViewModel) { + nav.nav(Route.Profile(user.pubkeyHex)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt new file mode 100644 index 000000000..9b0c9286c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListSelectUserScreen.kt @@ -0,0 +1,281 @@ +/* + * 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.newUser + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@Stable +class ImportFollowListSelectUserViewModel( + val account: Account, + val nip05Client: INip05Client, +) : ViewModel() { + var identifier by mutableStateOf("") + val userSuggestions = UserSuggestionState(account, nip05Client) + val listState = LazyListState(0, 0) + + val searchTerm = + userSuggestions.searchTerm + .onEach { + if (!it.isNullOrBlank()) { + listState.scrollToItem(0, 0) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, userSuggestions.currentWord) + + val results = + userSuggestions.results + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + class Factory( + val account: Account, + val nip05Client: INip05Client, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ImportFollowListSelectUserViewModel(account, nip05Client) as T + } +} + +@Composable +fun ImportFollowListSelectUserScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: ImportFollowListSelectUserViewModel = + viewModel( + factory = ImportFollowListSelectUserViewModel.Factory(accountViewModel.account, accountViewModel.nip05Client), + ) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopBarExtensibleWithBackButton( + title = {}, + popBack = nav::popBack, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + InputSelectUserBody( + viewModel = viewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun InputSelectUserBody( + viewModel: ImportFollowListSelectUserViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) { + ImportHeader() + Spacer(Modifier.height(20.dp)) + + OutlinedTextField( + value = viewModel.identifier, + onValueChange = { + viewModel.identifier = it + viewModel.userSuggestions.processCurrentWord(it) + }, + label = { Text(stringRes(R.string.profile_to_import_from)) }, + placeholder = { Text(stringRes(R.string.name_search_npub1_alice_example_com)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + supportingText = { Text(stringRes(R.string.supports_npub_nip_05_hex_and_namecoin_bit_d_id)) }, + ) + + Spacer(Modifier.height(8.dp)) + + CustomShowUserSuggestionList( + viewModel = viewModel, + onSelect = { user -> + nav.nav(Route.ImportFollowsPickFollows(user.pubkeyHex)) + }, + modifier = Modifier.weight(1f), + accountViewModel = accountViewModel, + ) + + Spacer(Modifier.height(16.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = nav::popBack) { Text(stringRes(R.string.skip_for_now)) } + } + } +} + +@Composable +private fun ImportHeader() { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + stringRes(R.string.import_follow_list), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + stringRes(R.string.start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun CustomShowUserSuggestionList( + viewModel: ImportFollowListSelectUserViewModel, + onSelect: (User) -> Unit, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + UserSearchDataSourceSubscription(viewModel.userSuggestions, accountViewModel) + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + viewModel.userSuggestions.invalidateData() + } + } + launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { + viewModel.userSuggestions.invalidateData() + } + } + } + + CustomWatchResponses(viewModel, onSelect, accountViewModel, modifier) +} + +@Composable +fun CustomWatchResponses( + viewModel: ImportFollowListSelectUserViewModel, + onSelect: (User) -> Unit, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val suggestions by viewModel.results.collectAsStateWithLifecycle() + + if (suggestions.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = modifier, + state = viewModel.listState, + ) { + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> + UserLine(item, accountViewModel) { onSelect(item) } + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier.padding(24.dp), + ) { + Text( + stringRes(R.string.tip), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(5.dp)) + Text( + stringRes(R.string.import_follows_tips), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index eb53679fd..a0cd1da9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -72,7 +72,7 @@ class CardFeedContentState( val feedContent = _feedContent.asStateFlow() // Simple counter that changes when it needs to invalidate everything - private val _scrollToTop = MutableStateFlow(0) + private val _scrollToTop = MutableStateFlow(0) val scrollToTop = _scrollToTop.asStateFlow() var scrolltoTopPending = false @@ -165,13 +165,13 @@ class CardFeedContentState( val reactionsPerEvent = mutableMapOf>() notes .filter { it.event is ReactionEvent } - .forEach { + .forEach { note -> val reactedPost = - it.replyTo?.lastOrNull { + note.replyTo?.lastOrNull { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent } if (reactedPost != null) { - reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it) + reactionsPerEvent.getOrPut(reactedPost) { mutableListOf() }.add(note) } } @@ -192,7 +192,7 @@ class CardFeedContentState( // var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event) // zapRequest.event = newZapRequestEvent zapsPerEvent - .getOrPut(zappedPost, { mutableListOf() }) + .getOrPut(zappedPost) { mutableListOf() } .add(CombinedZap(zapRequest, zapEvent)) } } else { @@ -215,7 +215,7 @@ class CardFeedContentState( val zapRequestNote = getNoteIfExists(zapRequest.id) if (zapRequestNote != null) { zapsPerUser - .getOrPut(author, { mutableListOf() }) + .getOrPut(author) { mutableListOf() } .add(CombinedZap(zapRequestNote, zapEvent)) } } @@ -226,13 +226,13 @@ class CardFeedContentState( val boostsPerEvent = mutableMapOf>() notes .filter { it.event is RepostEvent || it.event is GenericRepostEvent } - .forEach { + .forEach { note -> val boostedPost = - it.replyTo?.lastOrNull { + note.replyTo?.lastOrNull { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent } if (boostedPost != null) { - boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it) + boostsPerEvent.getOrPut(boostedPost) { mutableListOf() }.add(note) } } @@ -240,42 +240,41 @@ class CardFeedContentState( val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys val multiCards = - allBaseNotes - .map { baseNote -> - val boostsInCard = boostsPerEvent[baseNote] ?: emptyList() - val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() - val zapsInCard = zapsPerEvent[baseNote] ?: emptyList() + allBaseNotes.flatMap { baseNote -> + val boostsInCard = boostsPerEvent[baseNote] ?: emptyList() + val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() + val zapsInCard = zapsPerEvent[baseNote] ?: emptyList() - val singleList = - (boostsInCard + zapsInCard.map { it.response } + reactionsInCard).groupBy { - sdf.format( - Instant - .ofEpochSecond(it.createdAt() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), + val singleList = + (boostsInCard + zapsInCard.map { it.response } + reactionsInCard).groupBy { + sdf.format( + Instant + .ofEpochSecond(it.createdAt() ?: 0L) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(), + ) + } + + val days = singleList.keys.sortedBy { it } + + days + .mapNotNull { string -> + val sortedList = + singleList + .get(string) + ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + ?.reversed() + + sortedList?.chunked(30)?.map { chunk -> + MultiSetCard( + baseNote, + boostsInCard.filter { it in chunk }.toImmutableList(), + reactionsInCard.filter { it in chunk }.toImmutableList(), + zapsInCard.filter { it.response in chunk }.toImmutableList(), ) } - - val days = singleList.keys.sortedBy { it } - - days - .mapNotNull { - val sortedList = - singleList - .get(it) - ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - ?.reversed() - - sortedList?.chunked(30)?.map { chunk -> - MultiSetCard( - baseNote, - boostsInCard.filter { it in chunk }.toImmutableList(), - reactionsInCard.filter { it in chunk }.toImmutableList(), - zapsInCard.filter { it.response in chunk }.toImmutableList(), - ) - } - }.flatten() - }.flatten() + }.flatten() + } val userZaps = zapsPerUser @@ -290,10 +289,10 @@ class CardFeedContentState( ) } - byDay.values.map { + byDay.values.map { zaps -> ZapUserSetCard( user.key, - it + zaps .sortedWith(compareBy({ it.createdAt() }, { it.idHex() })) .reversed() .toImmutableList(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 38799f3bf..105d71a93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -82,9 +82,9 @@ class NotificationSummaryState( Instant.ofEpochSecond(createAt).atZone(ZoneId.systemDefault()).toLocalDateTime(), ) - fun today() = sdf.format(LocalDateTime.now()) + fun today(): String = sdf.format(LocalDateTime.now()) - public suspend fun initializeSuspend() { + suspend fun initializeSuspend() { checkNotInMainThread() val currentUser = user.pubkeyHex diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index adfe9b594..50988c065 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -58,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton @@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -211,7 +212,10 @@ fun PublicMessageScreenContent( DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { - ContentSensitivityExplainer() + ContentSensitivityExplainer( + description = postViewModel.contentWarningDescription, + onDescriptionChange = { postViewModel.contentWarningDescription = it }, + ) } if (postViewModel.wantsToAddGeoHash) { @@ -273,7 +277,7 @@ fun PublicMessageScreenContent( it, postViewModel::autocompleteWithUser, accountViewModel, - Modifier.heightIn(0.dp, 300.dp), + SuggestionListDefaultHeightPage, ) } @@ -282,7 +286,7 @@ fun PublicMessageScreenContent( it, postViewModel::autocompleteWithEmoji, postViewModel::autocompleteWithEmojiUrl, - Modifier.heightIn(0.dp, 300.dp), + SuggestionListDefaultHeightPage, ) } @@ -312,6 +316,14 @@ private fun BottomRowActions( postViewModel.selectImage(it) } + SelectFromFiles( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + TakePictureButton( onPictureTaken = { postViewModel.selectImage(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index daedcac62..7824f3974 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup @@ -168,6 +169,7 @@ class NewPublicMessageViewModel : // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") // GeoHash var wantsToAddGeoHash by mutableStateOf(false) @@ -191,7 +193,7 @@ class NewPublicMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account) + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05Client) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) @@ -266,6 +268,7 @@ class NewPublicMessageViewModel : wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null @@ -364,7 +367,7 @@ class NewPublicMessageViewModel : val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val contentWarningReason = if (wantsToMarkAsSensitive) "" else null + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null return PublicMessageEvent.build( to = toUsers.toList(), @@ -493,6 +496,7 @@ class NewPublicMessageViewModel : wantsForwardZapTo = false wantsToMarkAsSensitive = false + contentWarningDescription = "" wantsToAddGeoHash = false wantsSecretEmoji = false @@ -522,10 +526,15 @@ class NewPublicMessageViewModel : urlPreviews.update(newMessage) if (message.selection.collapsed) { - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + val lastWord = newMessage.currentWord() + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } - val lastWord = message.currentWord() - userSuggestions?.processCurrentWord(lastWord) emojiSuggestions?.processCurrentWord(lastWord) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt index f95b460c9..53fd1cf33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt @@ -37,9 +37,7 @@ class UserProfileBookmarksFeedFilter( override fun feed(): List { val note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) - val noteEvent = note.event as? BookmarkListEvent - - if (noteEvent == null) return emptyList() + val noteEvent = note.event as? BookmarkListEvent ?: return emptyList() val notes = noteEvent.publicBookmarks().mapNotNull { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index f36547074..d95d5f51b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -76,29 +76,28 @@ fun filterUserProfilePosts( user.outboxRelays()?.ifEmpty { null } ?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) - return relays - .map { relay -> - listOf( - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = UserProfilePostKinds1, - authors = listOf(user.pubkeyHex), - limit = 500, - since = since?.get(relay)?.time, - ), - ), - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = UserProfilePostKinds2, - authors = listOf(user.pubkeyHex), - limit = 50, - since = since?.get(relay)?.time, - ), - ), - ) - }.flatten() + return relays.flatMap { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds1, + authors = listOf(user.pubkeyHex), + limit = 500, + since = since?.get(relay)?.time, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds2, + authors = listOf(user.pubkeyHex), + limit = 50, + since = since?.get(relay)?.time, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt index 57030f718..6fe462fe8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt @@ -32,7 +32,7 @@ class UserProfileFollowersFilterSubAssembler( override fun updateFilter( key: UserProfileQueryState, since: SincePerRelayMap?, - ): List? = filterUserProfileFollowers(user(key), since) + ): List = filterUserProfileFollowers(user(key), since) override fun user(key: UserProfileQueryState) = key.user } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt index 78cde3b09..591010081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -34,7 +34,7 @@ class UserProfileMetadataFilterSubAssembler( override fun updateFilter( keys: List, since: SincePerRelayMap?, - ): List? { + ): List { val userPerRelay = mapOfSet { keys.mapTo(mutableSetOf()) { key -> key.user }.forEach { user -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index b194b7b94..b153ef37e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayCircleOutline import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -170,10 +171,13 @@ fun GalleryContentView( AutoNonlazyGrid(contentList.size, modifier = Modifier.fillMaxSize()) { contentIndex -> when (val content = contentList[contentIndex]) { is MediaUrlContent -> { - val hasSensitiveContent = - (content is MediaUrlVideo && content.contentWarning != null) || - (content is MediaUrlImage && content.contentWarning != null) - SensitivityWarning(hasSensitiveContent, accountViewModel) { + val sensitivityReason = + when (content) { + is MediaUrlVideo -> content.contentWarning + is MediaUrlImage -> content.contentWarning + else -> null + } + SensitivityWarning(sensitivityReason, accountViewModel) { UrlImageView(content, accountViewModel) } } @@ -227,7 +231,7 @@ fun UrlImageView( imageVector = Icons.Default.PlayCircleOutline, contentDescription = stringRes(id = R.string.play), modifier = Size50Modifier, - tint = Color.White, + tint = MaterialTheme.colorScheme.onBackground, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 1cd2949e9..2dd013878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -92,15 +93,14 @@ fun DisplayLNAddress( onClick = { zapExpanded = !zapExpanded }, modifier = Modifier - .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - .weight(1f), + .padding(top = 1.dp, bottom = 1.dp, start = 5.dp), ) } if (zapExpanded) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), ) { InvoiceRequestCard( lud16, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 83aa8448d..202a59ebc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -20,8 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header +import android.content.ClipData +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -35,12 +39,14 @@ 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -48,15 +54,18 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol +import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations @@ -65,8 +74,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.Displ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size15Modifier -import com.vitorpamplona.amethyst.ui.theme.Size16Modifier -import com.vitorpamplona.amethyst.ui.theme.Size25Modifier +import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -75,9 +83,11 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity +import kotlinx.coroutines.launch private const val IDENTITY_ICON_CACHE_KEY = 0 +@OptIn(ExperimentalLayoutApi::class) @Composable fun DrawAdditionalInfo( baseUser: User, @@ -89,147 +99,186 @@ fun DrawAdditionalInfo( val userState by observeUserInfo(baseUser, accountViewModel) val user = userState ?: return val uri = LocalUriHandler.current - val clipboardManager = LocalClipboardManager.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() val identities by externalIdentities.identities.collectAsStateWithLifecycle() - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { - CreateTextWithEmoji( - text = user.info.bestName() ?: baseUser.pubkeyDisplayHex(), - tags = user.tags, - fontWeight = FontWeight.Bold, - fontSize = 25.sp, - ) - Spacer(StdHorzSpacer) - user.info.pronouns?.let { + val displayName = user.info.bestName() + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = SpacedBy3dp) { + if (displayName != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 7.dp), + ) { + CreateTextWithEmoji( + text = displayName, + tags = user.tags, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + ) + Spacer(StdHorzSpacer) + user.info.pronouns?.let { + Text( + text = "($it)", + color = MaterialTheme.colorScheme.placeholderText, + fontSize = 14.sp, + ) + Spacer(StdHorzSpacer) + } + + DrawPlayName(displayName) + } + } + + if (displayName != user.info.name && !user.info.name.isNullOrBlank()) { Text( - text = "($it)", - ) - Spacer(StdHorzSpacer) - } - - user.info.bestName()?.let { - DrawPlayName(it) - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = baseUser.pubkeyDisplayHex(), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), - color = MaterialTheme.colorScheme.placeholderText, - ) - - IconButton( - modifier = - Modifier - .size(25.dp) - .padding(start = 5.dp), - onClick = { clipboardManager.setText(AnnotatedString(baseUser.pubkeyNpub())) }, - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringRes(id = R.string.copy_npub_to_clipboard), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.placeholderText, + color = MaterialTheme.colorScheme.placeholderText, + text = "@" + user.info.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - IconButton( - modifier = Size25Modifier, - onClick = { nav.nav(Route.QRDisplay(baseUser.pubkeyHex)) }, - ) { - Icon( - painter = painterRes(R.drawable.ic_qrcode, 1), - contentDescription = stringRes(id = R.string.show_npub_as_a_qr_code), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.placeholderText, - ) - } - } - - DisplayBadges(baseUser, accountViewModel, nav) - - DisplayNip05ProfileStatus(baseUser, accountViewModel) - - val website = user.info.website - if (!website.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - tint = MaterialTheme.colorScheme.placeholderText, - imageVector = Icons.Default.Link, - contentDescription = stringRes(R.string.website), - modifier = Modifier.size(16.dp), + Text( + text = baseUser.pubkeyDisplayHex(), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, ) - ClickableTextPrimary( - text = website.removePrefix("https://"), + IconButton( + modifier = Modifier.size(23.dp).padding(start = 5.dp), onClick = { - website.let { - runCatching { - if (it.contains("://")) { - uri.openUri(it) - } else { - uri.openUri("http://$it") - } - } + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("npub", baseUser.pubkeyNpub()))) } }, - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp), - ) - } - } - - val lud16 = - remember(userState) { - userState?.info?.lud16?.trim() - ?: userState?.info?.lud06?.trim() - } - DisplayLNAddress(lud16, baseUser, accountViewModel, nav) - - val displayIdentities = identities.ifEmpty { user.identities } - if (displayIdentities.isNotEmpty()) { - displayIdentities.forEach { identity: IdentityClaimTag -> - Row(verticalAlignment = Alignment.CenterVertically) { + ) { Icon( - tint = Color.Unspecified, - painter = painterRes(resourceId = getIdentityClaimIcon(identity), IDENTITY_ICON_CACHE_KEY), - contentDescription = stringRes(getIdentityClaimDescription(identity)), - modifier = Modifier.size(16.dp), - ) - - ClickableTextPrimary( - text = identity.identity, - onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, - modifier = - Modifier - .padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - .weight(1f), + imageVector = Icons.Default.ContentCopy, + contentDescription = stringRes(id = R.string.copy_npub_to_clipboard), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, ) } } - } - user.info.about?.let { - Row( - modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), - ) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - TranslatableRichTextViewer( - content = it, - canPreview = false, - quotesLeft = 1, - tags = user.tags, - backgroundColor = background, - id = it, - accountViewModel = accountViewModel, - nav = nav, + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = baseUser.toNProfile().toShortDisplay(6), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, ) - } - } - DisplayAppRecommendations(appRecommendations, accountViewModel, nav) + IconButton( + modifier = Modifier.size(23.dp).padding(start = 5.dp), + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("nprofile", baseUser.toNProfile()))) + } + }, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringRes(id = R.string.copy_nprofile_to_clipboard), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + + IconButton( + modifier = Modifier.size(23.dp), + onClick = { nav.nav(Route.QRDisplay(baseUser.pubkeyHex)) }, + ) { + Icon( + painter = painterRes(R.drawable.ic_qrcode, 1), + contentDescription = stringRes(id = R.string.show_nprofile_as_a_qr_code), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + + DisplayNip05ProfileStatus(baseUser, accountViewModel) + + val lud16 = + remember(userState) { + userState?.info?.lud16?.trim() + ?: userState?.info?.lud06?.trim() + } + DisplayLNAddress(lud16, baseUser, accountViewModel, nav) + + val website = user.info.website + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = MaterialTheme.colorScheme.placeholderText, + imageVector = Icons.Default.Link, + contentDescription = stringRes(R.string.website), + modifier = Modifier.size(18.dp), + ) + + ClickableTextPrimary( + text = website.removePrefix("https://").removePrefix("http://").removeSuffix("/"), + onClick = { + runCatching { + if (website.contains("://")) { + uri.openUri(website) + } else { + uri.openUri("http://$website") + } + } + }, + modifier = Modifier.padding(vertical = 1.dp, horizontal = 5.dp), + ) + } + } + + val displayIdentities = identities.ifEmpty { user.identities } + if (displayIdentities.isNotEmpty()) { + displayIdentities.forEach { identity: IdentityClaimTag -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = Color.Unspecified, + painter = painterRes(resourceId = getIdentityClaimIcon(identity), IDENTITY_ICON_CACHE_KEY), + contentDescription = stringRes(getIdentityClaimDescription(identity)), + modifier = Modifier.size(18.dp), + ) + + ClickableTextPrimary( + text = identity.identity, + onClick = { runCatching { uri.openUri(identity.toProofUrl()) } }, + modifier = Modifier.padding(horizontal = 5.dp), + ) + } + } + } + + DisplayBadges(baseUser, accountViewModel, nav) + + user.info.about?.let { + Row( + modifier = Modifier.padding(top = 10.dp, bottom = 5.dp), + ) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = it, + canPreview = false, + quotesLeft = 1, + tags = user.tags, + backgroundColor = background, + id = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + DisplayAppRecommendations(appRecommendations, accountViewModel, nav) + } } @Composable @@ -242,24 +291,22 @@ fun DisplayNip05ProfileStatus( when (val nip05State = nip05StateMetadata) { is Nip05State.Exists -> { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = SpacedBy5dp) { - ObserveAndRenderNIP05VerifiedSymbol(nip05State, 2, Size16Modifier, accountViewModel) - - if (nip05State.nip05.name != "_") { - Text( - text = "${nip05State.nip05.name}@", - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + ObserveAndRenderNIP05VerifiedSymbol(nip05State, 2, Size15Modifier, accountViewModel) val uri = LocalUriHandler.current + val color = MaterialTheme.colorScheme.primary - ClickableTextPrimary( - text = nip05State.nip05.domain, - onClick = { runCatching { uri.openUri("https://${nip05State.nip05.domain}") } }, + Text( + text = + remember(nip05State) { + buildAnnotatedString { + appendLink(nip05State.nip05.toValue(), color) { + runCatching { uri.openUri("https://${nip05State.nip05.domain}") } + } + } + }, modifier = Modifier.padding(top = 1.dp, bottom = 1.dp), + softWrap = true, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt index 0d6f9d4fa..2027b5106 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt @@ -38,7 +38,7 @@ class UserProfileReportsFeedFilter( private fun innerApplyFilter(collection: Collection): Set = collection - .filterTo(mutableSetOf()) { + .filterTo(mutableSetOf()) { it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt index 54ef78f54..fa56b9875 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt @@ -131,7 +131,7 @@ fun RelayFeedActionOptions( R.string.login_with_a_private_key_to_be_able_to_unfollow, ) } else { - accountViewModel.unfollowFavoriteRelay(relayUrl) + accountViewModel.unfollowRelayFeed(relayUrl) } } } else { @@ -142,7 +142,7 @@ fun RelayFeedActionOptions( R.string.login_with_a_private_key_to_be_able_to_follow, ) } else { - accountViewModel.followFavoriteRelay(relayUrl) + accountViewModel.followRelayFeed(relayUrl) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 44f9361a5..508c8ae09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -55,8 +55,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedR import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.favorites.FavoriteRelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.favorites.renderFavoriteItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds.RelayFeedsListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds.renderRelayFeedsItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel @@ -95,7 +95,7 @@ fun AllRelayListScreen( val broadcastViewModel: BroadcastRelayListViewModel = viewModel() val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() - val favoriteViewModel: FavoriteRelayListViewModel = viewModel() + val relayFeedsViewModel: RelayFeedsListViewModel = viewModel() dmViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel) @@ -108,7 +108,7 @@ fun AllRelayListScreen( broadcastViewModel.init(accountViewModel) indexerViewModel.init(accountViewModel) proxyViewModel.init(accountViewModel) - favoriteViewModel.init(accountViewModel) + relayFeedsViewModel.init(accountViewModel) LaunchedEffect(accountViewModel) { dmViewModel.load() @@ -122,7 +122,7 @@ fun AllRelayListScreen( broadcastViewModel.load() indexerViewModel.load() proxyViewModel.load() - favoriteViewModel.load() + relayFeedsViewModel.load() } MappedAllRelayListView( @@ -137,7 +137,7 @@ fun AllRelayListScreen( broadcastViewModel, indexerViewModel, proxyViewModel, - favoriteViewModel, + relayFeedsViewModel, accountViewModel, nav, ) @@ -157,7 +157,7 @@ fun MappedAllRelayListView( broadcastViewModel: BroadcastRelayListViewModel, indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, - favoriteViewModel: FavoriteRelayListViewModel, + relayFeedsViewModel: RelayFeedsListViewModel, accountViewModel: AccountViewModel, newNav: INav, ) { @@ -173,7 +173,7 @@ fun MappedAllRelayListView( val broadcastRelays by broadcastViewModel.relays.collectAsStateWithLifecycle() val indexerRelays by indexerViewModel.relays.collectAsStateWithLifecycle() val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() - val favoriteFeedState by favoriteViewModel.relays.collectAsStateWithLifecycle() + val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -190,7 +190,7 @@ fun MappedAllRelayListView( broadcastViewModel.clear() indexerViewModel.clear() proxyViewModel.clear() - favoriteViewModel.clear() + relayFeedsViewModel.clear() newNav.popBack() }, onPost = { @@ -204,7 +204,7 @@ fun MappedAllRelayListView( broadcastViewModel.create() indexerViewModel.create() proxyViewModel.create() - favoriteViewModel.create() + relayFeedsViewModel.create() newNav.popBack() }, ) @@ -327,7 +327,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderFavoriteItems(favoriteFeedState, favoriteViewModel, accountViewModel, newNav) + renderRelayFeedsItems(relayFeedsFeedState, relayFeedsViewModel, accountViewModel, newNav) item { SettingsCategory( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 07862a7dd..64ebae295 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues @@ -42,6 +43,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.automirrored.filled.ContactSupport +import androidx.compose.material.icons.automirrored.filled.Feed import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.Message @@ -70,6 +72,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip @@ -97,6 +100,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.appendLink @@ -105,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.graspLink import com.vitorpamplona.amethyst.ui.note.nipLink import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot @@ -113,7 +118,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.Height25Modifier import com.vitorpamplona.amethyst.ui.theme.Size100dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow @@ -140,7 +149,6 @@ import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProvide import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage @@ -196,9 +204,9 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -340,10 +348,16 @@ fun RelayInformationBody( accountViewModel: AccountViewModel, nav: INav, ) { - val nostrClient = remember { Amethyst.instance.client as? NostrClient } - val activeReqs = remember(relay) { nostrClient?.activeRequests(relay) ?: emptyMap() } - val activeCounts = remember(relay) { nostrClient?.activeCounts(relay) ?: emptyMap() } - val activeOutbox = remember(relay) { nostrClient?.activeOutboxCache(relay) ?: emptySet() } + val activeReqs = remember(relay) { accountViewModel.account.client.activeRequests(relay) } + val activeCounts = remember(relay) { accountViewModel.account.client.activeCounts(relay) } + val activeOutbox = remember(relay) { accountViewModel.account.client.activeOutboxCache(relay) } + + val usedBy = + remember(relay) { + accountViewModel.account.declaredFollowsPerUsingRelay.value[relay]?.mapNotNull { hex -> + LocalCache.checkGetOrCreateUser(hex) + } ?: emptyList() + } LazyColumn( modifier = @@ -356,7 +370,7 @@ fun RelayInformationBody( ) { // 1. Header Section item { - RelayHeader(relay, relayStats, relayInfo, accountViewModel) + RelayHeader(relay, relayStats, relayInfo, accountViewModel, nav) } val targetAudience = @@ -407,6 +421,37 @@ fun RelayInformationBody( item { SoftwareCard(relayInfo) } } + if (usedBy.isNotEmpty()) { + item { + SectionHeader(stringRes(R.string.used_by)) + } + item { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + FlowRow(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.Center) { + usedBy.take(30).forEach { + UserPicture( + user = it, + size = Size25dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + if (usedBy.size > 30) { + Box(contentAlignment = Alignment.Center, modifier = Height25Modifier) { + Text( + text = stringRes(R.string.and_more, usedBy.size - 30), + maxLines = 1, + ) + } + } + } + } + } + } + // Active subscriptions and outbox section val hasReqs = activeReqs.isNotEmpty() val hasCounts = activeCounts.isNotEmpty() @@ -498,7 +543,7 @@ private fun kindDisplayName(kind: Int): Int = CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp ChessGameEvent.KIND -> R.string.kind_chess_games JesterEvent.KIND -> R.string.kind_chess_auth - FavoriteRelayListEvent.KIND -> R.string.kind_favorite_relays + RelayFeedsListEvent.KIND -> R.string.kind_favorite_relays LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept LiveChessMoveEvent.KIND -> R.string.kind_chess_move @@ -1021,9 +1066,11 @@ private fun RelayHeader( relayStats: RelayStat, relayInfo: Nip11RelayInformation, accountViewModel: AccountViewModel, + nav: INav, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = SpacedBy10dp, modifier = Modifier .fillMaxWidth() @@ -1040,13 +1087,25 @@ private fun RelayHeader( .size(Size100dp) .clip(shape = CircleShape), ) - Spacer(modifier = Modifier.height(12.dp)) Text( text = relayInfo.description ?: relay.displayUrl(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + OutlinedButton( + modifier = Modifier.padding(horizontal = 30.dp), + shape = ButtonBorder, + onClick = { nav.nav(Route.RelayFeed(url = relay.url)) }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Feed, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringRes(R.string.see_relay_feed)) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt index 7781de72f..3cd574c4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -69,13 +70,18 @@ fun LazyListScope.renderBlockedItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt index 25081c10b..abaf3faab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = + override fun getRelayList(): List = account.blockedRelayList.flow.value .toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt index 74cf76eb6..6f55c5ec9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -70,12 +71,17 @@ fun LazyListScope.renderBroadcastItems( item, onDelete = { postViewModel.deleteRelay(item) }, accountViewModel = accountViewModel, - nav, + nip11CachedRetriever = Amethyst.instance.nip11Cache, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt index 608eb9405..1c94de0f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class BroadcastRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = + override fun getRelayList(): List = account.broadcastRelayList.flow.value .toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index ed3c8ab93..73fad951f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon @@ -46,7 +47,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.Height25Modifier import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth @@ -61,6 +61,8 @@ fun BasicRelaySetupInfoClickableRow( loadRobohash: Boolean, onDelete: ((BasicRelaySetupInfo) -> Unit)?, onClick: () -> Unit, + nip11CachedRetriever: Nip11CachedRetriever, + modifier: Modifier = Modifier, accountViewModel: AccountViewModel, nav: INav, ) { @@ -77,9 +79,9 @@ fun BasicRelaySetupInfoClickableRow( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = HalfVertPadding, + modifier = modifier, ) { - val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay) + val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay, nip11CachedRetriever) RenderRelayIcon( iconUrlFromRelayInfoDoc.id ?: item.relay.displayUrl(), @@ -102,17 +104,12 @@ fun BasicRelaySetupInfoClickableRow( UsedBy(item, accountViewModel, nav) - Row( - verticalAlignment = Alignment.CenterVertically, + RelayStatusRow( + item = item, + onClick = onClick, modifier = ReactionRowHeightChatMaxWidth, - ) { - RelayStatusRow( - item = item, - onClick = onClick, - modifier = Modifier.weight(1f), - accountViewModel = accountViewModel, - ) - } + accountViewModel = accountViewModel, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 3d25f11cd..1a85e2aec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -21,13 +21,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding @Composable fun BasicRelaySetupInfoDialog( item: BasicRelaySetupInfo, + nip11CachedRetriever: Nip11CachedRetriever, onDelete: ((BasicRelaySetupInfo) -> Unit)?, accountViewModel: AccountViewModel, nav: INav, @@ -37,8 +40,10 @@ fun BasicRelaySetupInfoDialog( loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), onDelete = onDelete, - accountViewModel = accountViewModel, onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, + nip11CachedRetriever = nip11CachedRetriever, + modifier = HalfVertPadding, + accountViewModel = accountViewModel, nav = nav, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt index 2cc3243a7..fff469fde 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt @@ -20,8 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common +import android.R.attr.onClick import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Download @@ -31,19 +34,55 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.service.countToHumanReadable import com.vitorpamplona.amethyst.service.countToHumanReadableBytes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface import com.vitorpamplona.amethyst.ui.theme.warningColor +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl + +@Composable +@Preview +fun RelayStatusRowPreview() { + RelayStatusRow( + BasicRelaySetupInfo( + "nos.lol".normalizeRelayUrl(), + relayStat = + RelayStat( + receivedBytes = 10000, + sentBytes = 10000, + spamCounter = 3, + errorCounter = 4, + pingInMs = 300, + compression = true, + connectionTentatives = 20, + connectionCompleted = 10, + ), + paidRelay = true, + forcesTor = true, + users = listOf(), + ), + onClick = {}, + modifier = Modifier, + accountViewModel = mockAccountViewModel(), + ) +} @Composable fun RelayStatusRow( @@ -53,140 +92,151 @@ fun RelayStatusRow( accountViewModel: AccountViewModel, ) { Row( - modifier = - modifier.pointerInput(Unit) { - detectTapGestures( - onTap = { - onClick() - }, - onLongPress = { - accountViewModel.toastManager.toast( - R.string.read_from_relay, - R.string.read_from_relay_description, - ) - }, - ) - }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = modifier, ) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringRes(R.string.read_from_relay), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.allGoodColor, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.receivedBytes), - maxLines = 1, - fontSize = Font12SP, - modifier = HalfStartPadding, - color = MaterialTheme.colorScheme.placeholderText, - ) - } - - Row( - modifier = - modifier.pointerInput(Unit) { - detectTapGestures( - onTap = { - onClick() - }, - onLongPress = { - accountViewModel.toastManager.toast( - R.string.write_to_relay, - R.string.write_to_relay_description, - ) - }, - ) - }, - ) { - Icon( - imageVector = Icons.Default.Upload, - stringRes(R.string.write_to_relay), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.allGoodColor, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.sentBytes), - maxLines = 1, - fontSize = Font12SP, - modifier = HalfStartPadding, - color = MaterialTheme.colorScheme.placeholderText, - ) - } - - Row( - modifier = - modifier.pointerInput(Unit) { - detectTapGestures( - onTap = { - onClick() - }, - onLongPress = { - accountViewModel.toastManager.toast( - R.string.errors, - R.string.errors_description, - ) - }, - ) - }, - ) { - Icon( - imageVector = Icons.Default.SyncProblem, - stringRes(R.string.errors), - modifier = Size15Modifier, - tint = - if (item.relayStat.errorCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor + Row( + modifier = + Modifier.weight(1f).pointerInput(Unit) { + detectTapGestures( + onTap = { + onClick() + }, + onLongPress = { + accountViewModel.toastManager.toast( + R.string.read_from_relay, + R.string.read_from_relay_description, + ) + }, + ) }, - ) + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringRes(R.string.read_from_relay), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.allGoodColor, + ) - Text( - text = countToHumanReadable(item.relayStat.errorCounter, "errors"), - maxLines = 1, - fontSize = Font12SP, - modifier = HalfStartPadding, - color = MaterialTheme.colorScheme.placeholderText, - ) - } + Text( + text = countToHumanReadableBytes(item.relayStat.receivedBytes), + maxLines = 1, + fontSize = Font12SP, + modifier = HalfStartPadding, + color = MaterialTheme.colorScheme.placeholderText, + ) + } - Row( - modifier = - modifier.pointerInput(Unit) { - detectTapGestures( - onTap = { - onClick() - }, - onLongPress = { - accountViewModel.toastManager.toast( - R.string.spam, - R.string.spam_description, - ) - }, - ) - }, - ) { - Icon( - imageVector = Icons.Default.DeleteSweep, - stringRes(R.string.spam), - modifier = Size15Modifier, - tint = - if (item.relayStat.spamCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor + Row( + modifier = + Modifier.weight(1f).pointerInput(Unit) { + detectTapGestures( + onTap = { + onClick() + }, + onLongPress = { + accountViewModel.toastManager.toast( + R.string.write_to_relay, + R.string.write_to_relay_description, + ) + }, + ) }, - ) + ) { + Icon( + imageVector = Icons.Default.Upload, + stringRes(R.string.write_to_relay), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.allGoodColor, + ) - Text( - text = countToHumanReadable(item.relayStat.spamCounter, "spam"), - maxLines = 1, - fontSize = Font12SP, - modifier = HalfStartPadding, - color = MaterialTheme.colorScheme.placeholderText, - ) + Text( + text = countToHumanReadableBytes(item.relayStat.sentBytes), + maxLines = 1, + fontSize = Font12SP, + modifier = HalfStartPadding, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + + Row( + modifier = + Modifier.weight(1.3f).pointerInput(Unit) { + detectTapGestures( + onTap = { + onClick() + }, + onLongPress = { + accountViewModel.toastManager.toast( + R.string.errors, + R.string.connection_success_rate_description, + ) + }, + ) + }, + ) { + val successRate = ((item.relayStat.connectionCompleted / item.relayStat.connectionTentatives.toFloat()) * 100).toInt() + + Icon( + imageVector = Icons.Default.SyncProblem, + stringRes(R.string.errors), + modifier = Size15Modifier, + tint = + if (successRate < 0.1) { + MaterialTheme.colorScheme.redColorOnSecondSurface + } else if (successRate < 0.60) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) + + Text( + text = stringResource(R.string.uptime, successRate), + maxLines = 1, + fontSize = Font12SP, + modifier = HalfStartPadding, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + + Row( + modifier = + Modifier.weight(0.9f).padding(end = 5.dp).pointerInput(Unit) { + detectTapGestures( + onTap = { + onClick() + }, + onLongPress = { + accountViewModel.toastManager.toast( + R.string.spam, + R.string.spam_description, + ) + }, + ) + }, + horizontalArrangement = Arrangement.End, + ) { + Icon( + imageVector = Icons.Default.DeleteSweep, + stringRes(R.string.spam), + modifier = Size15Modifier, + tint = + if (item.relayStat.spamCounter > 0) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) + + Text( + text = countToHumanReadable(item.relayStat.spamCounter, "spam"), + maxLines = 1, + fontSize = Font12SP, + modifier = HalfStartPadding, + color = MaterialTheme.colorScheme.placeholderText, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt index cd772c2f9..d23bd28a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common +import android.R.id.input import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -32,11 +33,11 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @Stable -class RelaySuggestionState { +class RelaySuggestionState : IRelaySuggestionState { val currentInput = MutableStateFlow("") @OptIn(FlowPreview::class) - val results = + override val results = currentInput .debounce(300) .distinctUntilChanged() @@ -45,18 +46,28 @@ class RelaySuggestionState { val lower = input.lowercase() LocalCache.relayHints.relayDB .filter { _, relay -> relay.url.contains(lower) } - .sortedBy { it.url } + .map { relaySetupInfoBuilder(it) } + .sortedByDescending { it.relayStat.receivedBytes } .take(20) } else { - emptyList() + emptyList() } }.flowOn(Dispatchers.IO) - fun processInput(input: String) { + override fun processInput(input: String) { currentInput.tryEmit(input) } - fun reset() { + override fun reset() { currentInput.tryEmit("") } } + +@Stable +interface IRelaySuggestionState { + val results: Flow> + + fun processInput(input: String) + + fun reset() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index cdbf1a048..35441b625 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -20,13 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardElevation import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -35,37 +37,115 @@ 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.graphics.Color import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding +import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect +import com.vitorpamplona.amethyst.ui.theme.StdEndPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow @Preview @Composable fun RelayUrlEditFieldPreview() { + val suggestions = + listOf( + BasicRelaySetupInfo( + relay = "wss://nos.lol".normalizeRelayUrl(), + relayStat = + RelayStat( + receivedBytes = 1000, + sentBytes = 1000, + spamCounter = 12, + errorCounter = 0, + ), + ), + BasicRelaySetupInfo( + relay = "wss://nostr.mom".normalizeRelayUrl(), + relayStat = + RelayStat( + receivedBytes = 1000, + sentBytes = 1000, + spamCounter = 12, + errorCounter = 0, + ), + ), + ) + + val relaySuggestions = + remember { + object : IRelaySuggestionState { + override val results: Flow> = + MutableStateFlow(suggestions) + + override fun processInput(input: String) {} + + override fun reset() {} + } + } + + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { - RelayUrlEditField {} + RelayUrlEditField( + onNewRelay = {}, + relaySuggestions = relaySuggestions, + nip11CachedRetriever = Nip11CachedRetriever { TODO() }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) } } @Composable -fun RelayUrlEditField(onNewRelay: (NormalizedRelayUrl) -> Unit) { - var url by remember { mutableStateOf("") } +fun RelayUrlEditField( + onNewRelay: (NormalizedRelayUrl) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { val relaySuggestions = remember { RelaySuggestionState() } + val nip11CachedRetriever = remember { Amethyst.instance.nip11Cache } + RelayUrlEditField( + onNewRelay = onNewRelay, + relaySuggestions = relaySuggestions, + nip11CachedRetriever = nip11CachedRetriever, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun RelayUrlEditField( + onNewRelay: (NormalizedRelayUrl) -> Unit, + relaySuggestions: IRelaySuggestionState, + nip11CachedRetriever: Nip11CachedRetriever, + accountViewModel: AccountViewModel, + nav: INav, +) { + var url by remember { mutableStateOf("") } fun submitRelay() { - if (url.isNotBlank() && url != "/") { + if (url.isNotBlank()) { val relay = RelayUrlNormalizer.normalizeOrNull(url) if (relay != null) { onNewRelay(relay) @@ -76,59 +156,69 @@ fun RelayUrlEditField(onNewRelay: (NormalizedRelayUrl) -> Unit) { } Column { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { - url = it - relaySuggestions.processInput(it) - }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - keyboardOptions = - KeyboardOptions.Default.copy( - autoCorrectEnabled = false, - imeAction = ImeAction.Go, - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - keyboardActions = - KeyboardActions( - onGo = { submitRelay() }, - ), - ) - - Button( - onClick = { submitRelay() }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = - if (url.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - ) { - Text(text = stringRes(id = R.string.add), color = Color.White) - } - } - - ShowRelaySuggestionList( - relaySuggestions = relaySuggestions, - onSelect = { relay -> - url = relay.url - relaySuggestions.reset() + OutlinedTextField( + label = { Text(text = stringRes(R.string.add_a_relay)) }, + modifier = Modifier.fillMaxWidth(), + value = url, + onValueChange = { + url = it + relaySuggestions.processInput(it) + }, + placeholder = { + Text( + text = "server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + autoCorrectEnabled = false, + imeAction = ImeAction.Go, + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + keyboardActions = + KeyboardActions( + onGo = { submitRelay() }, + ), + trailingIcon = { + Button( + onClick = { submitRelay() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (url.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + modifier = StdEndPadding, + ) { + Text(text = stringRes(id = R.string.add), color = Color.White) + } }, ) + + Card( + modifier = Modifier.padding(horizontal = 1.dp), + elevation = cardElevation(5.dp), + shape = PopupUpEffect, + ) { + ShowRelaySuggestionList( + relaySuggestions = relaySuggestions, + onSelect = { relay -> + url = relay.url + relaySuggestions.reset() + }, + modifier = HalfHorzPadding, + nip11CachedRetriever = nip11CachedRetriever, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt index 8ce412ce6..39106f361 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt @@ -20,36 +20,45 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common -import androidx.compose.foundation.clickable +import android.R.attr.onClick import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @Composable fun ShowRelaySuggestionList( - relaySuggestions: RelaySuggestionState, + relaySuggestions: IRelaySuggestionState, onSelect: (NormalizedRelayUrl) -> Unit, modifier: Modifier = Modifier, + nip11CachedRetriever: Nip11CachedRetriever, + accountViewModel: AccountViewModel, + nav: INav, ) { val suggestions by relaySuggestions.results.collectAsStateWithLifecycle(emptyList()) if (suggestions.isNotEmpty()) { Column(modifier = modifier) { - suggestions.forEachIndexed { index, relay -> - RelayUrlLine(relay) { onSelect(relay) } + suggestions.forEachIndexed { index, relayInfo -> + BasicRelaySetupInfoClickableRow( + item = relayInfo, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + onClick = { onSelect(relayInfo.relay) }, + onDelete = null, + nip11CachedRetriever = nip11CachedRetriever, + modifier = HalfVertPadding, + accountViewModel = accountViewModel, + nav = nav, + ) if (index < suggestions.lastIndex) { HorizontalDivider(thickness = DividerThickness) } @@ -57,22 +66,3 @@ fun ShowRelaySuggestionList( } } } - -@Composable -fun RelayUrlLine( - relay: NormalizedRelayUrl, - onClick: () -> Unit, -) { - Text( - text = relay.displayUrl(), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt index 03630f3ef..13a8e6c04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,8 +65,9 @@ fun LazyListScope.renderConnectedItems( BasicRelaySetupInfoDialog( item, onDelete = null, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index 58bd31dc1..67ca079d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -34,16 +36,27 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { return relayList .map { + val reqs = Amethyst.instance.client.activeRequests(it) + + val users = mutableSetOf() + reqs.forEach { id, filters -> + filters.forEach { filter -> + filter.authors?.forEach { pubkey -> + users.add(pubkey) + } + filter.tags?.get("p")?.forEach { pubkey -> + users.add(pubkey) + } + } + } + BasicRelaySetupInfo( relay = it, relayStat = Amethyst.instance.relayStats.get(it), forcesTor = Amethyst.instance.torEvaluatorFlow.flow.value .useTor(it), - users = - account.declaredFollowsPerRelay.value[it]?.mapNotNull { hex -> - LocalCache.checkGetOrCreateUser(hex) - } ?: emptyList(), + users = users.mapNotNull { hex -> LocalCache.checkGetOrCreateUser(hex) }, ) }.distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 7b772ebb9..37e02b99b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -68,6 +69,7 @@ fun LazyListScope.renderDMItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, nav = nav, ) @@ -75,6 +77,10 @@ fun LazyListScope.renderDMItems( item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt index f8af962a3..e9575fc0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt @@ -18,7 +18,7 @@ * 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.relays.favorites +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -40,8 +41,8 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @Composable -fun FavoriteRelayList( - postViewModel: FavoriteRelayListViewModel, +fun RelayFeedsList( + postViewModel: RelayFeedsListViewModel, accountViewModel: AccountViewModel, onClose: () -> Unit, nav: INav, @@ -54,28 +55,33 @@ fun FavoriteRelayList( LazyColumn( contentPadding = FeedPadding, ) { - renderFavoriteItems(feedState, postViewModel, accountViewModel, newNav) + renderRelayFeedsItems(feedState, postViewModel, accountViewModel, newNav) } } } -fun LazyListScope.renderFavoriteItems( +fun LazyListScope.renderRelayFeedsItems( feedState: List, - postViewModel: FavoriteRelayListViewModel, + postViewModel: RelayFeedsListViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Favorite" + item.relay.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "RelayFeeds" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListViewModel.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListViewModel.kt index a0d3ff0a6..aab6d86fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListViewModel.kt @@ -18,19 +18,19 @@ * 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.relays.favorites +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable -class FavoriteRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = - account.favoriteRelayList.flow.value +class RelayFeedsListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List = + account.relayFeedsList.flow.value .toList() override suspend fun saveRelayList(urlList: List) { - account.saveFavoriteRelayList(urlList) + account.saveRelayFeedsList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt index cd7700ed7..1af2490cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -69,13 +70,18 @@ fun LazyListScope.renderIndexerItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index b4af90aeb..837966624 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = + override fun getRelayList(): List = account.indexerRelayList.flowNoDefaults.value .toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt index 906364321..8733ddbf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -68,13 +69,18 @@ fun LazyListScope.renderLocalItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index f0aa41cca..212e1b3b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -68,13 +69,18 @@ fun LazyListScope.renderPrivateOutboxItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 628864a70..a3e5f9f41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = + override fun getRelayList(): List = account.privateStorageRelayList.flow.value .toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 9b4f3a6bf..7ac2497f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -111,14 +112,19 @@ fun LazyListScope.renderNip65HomeItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addHomeRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addHomeRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -132,13 +138,18 @@ fun LazyListScope.renderNip65NotifItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteNotifRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addNotifRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addNotifRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt index 409edf821..2997a5c39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -69,13 +70,18 @@ fun LazyListScope.renderProxyItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt index 0d32e58c4..e2a0f1359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = + override fun getRelayList(): List = account.proxyRelayList.flow.value .toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index c28dc4a7b..15f697596 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -69,13 +70,18 @@ fun LazyListScope.renderSearchItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index bbbbfc148..e2e3ba0aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class SearchRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = + override fun getRelayList(): List = account.searchRelayList.flowNoDefaults.value .toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt index 1884774df..f8e0e305e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -69,13 +70,18 @@ fun LazyListScope.renderTrustedItems( BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, accountViewModel = accountViewModel, - nav, + nav = nav, ) } item { Spacer(modifier = StdVertSpacer) - RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + RelayUrlEditField( + onNewRelay = { postViewModel.addRelay(relaySetupInfoBuilder(it)) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt index 057c71098..c6b46496e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class TrustedRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = + override fun getRelayList(): List = account.trustedRelayList.flow.value .toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 8855dd149..028b9eec3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -29,26 +30,38 @@ import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.Rfc3986 +import com.vitorpamplona.quartz.utils.startsWithAny import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -57,13 +70,14 @@ import kotlinx.coroutines.flow.update @OptIn(FlowPreview::class) class SearchBarViewModel( val account: Account, + val nip05Client: INip05Client, ) : ViewModel(), InvalidatableContent { val focusRequester = FocusRequester() var searchValue by mutableStateOf("") - val invalidations = MutableStateFlow(0) - val searchValueFlow = MutableStateFlow("") + val invalidations = MutableStateFlow(0) + val searchValueFlow = MutableStateFlow("") val searchTerm = searchValueFlow @@ -74,40 +88,75 @@ class SearchBarViewModel( val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) - /** - * Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and - * returns the matching [User] from LocalCache, or null. - */ - private val namecoinResolvedUser = - searchValueFlow + val listState: LazyListState = LazyListState(0, 0) + + val directNip05Resolver: Flow = + searchTerm .debounce(400) - .distinctUntilChanged() - .filter { NamecoinNameResolver.isNamecoinIdentifier(it) } - .map { term -> - try { - val result = Amethyst.instance.namecoinResolver.resolve(term) - if (result != null) { - LocalCache.getOrCreateUser(result.pubkey) - } else { - null - } - } catch (_: Exception) { + .mapLatest { term -> + if (term.contains('@')) { + runCatching { + Nip05Id.parse(term)?.let { nip05 -> + nip05Client.get(nip05)?.let { info -> + val user = account.cache.checkGetOrCreateUser(info.pubkey) + if (user != null) { + info.relays.forEach { + it.normalizeRelayUrlOrNull()?.let { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) + } + } + } + user + } + } + }.getOrNull() + } else if (term.startsWithAny(userUriPrefixes)) { + runCatching { + Nip19Parser.uriToRoute(term)?.entity?.let { parsed -> + when (parsed) { + is NSec -> { + account.cache.getOrCreateUser(parsed.toPubKey().toHexKey()) + } + + is NPub -> { + account.cache.getOrCreateUser(parsed.hex) + } + + is NProfile -> { + val user = account.cache.getOrCreateUser(parsed.hex) + parsed.relay.forEach { relay -> + account.cache.relayHints.addKey(user.pubkey(), relay) + } + user + } + + else -> { + null + } + } + } + }.getOrNull() + } else if (term.length == 64 && Hex.isHex64(term)) { + account.cache.getOrCreateUser(term) + } else { null } }.flowOn(Dispatchers.IO) - .stateIn(viewModelScope, WhileSubscribed(5000), null) val searchResultsUsers = combine( searchValueFlow.debounce(100), invalidations.debounce(100), - namecoinResolvedUser, - ) { term, version, namecoinUser -> - val localResults = LocalCache.findUsersStartingWith(term, account) - if (namecoinUser != null && localResults.none { it.pubkeyHex == namecoinUser.pubkeyHex }) { - listOf(namecoinUser) + localResults + directNip05Resolver, + ) { term, version, nip05Resolver -> + if (nip05Resolver != null) { + return@combine listOf(nip05Resolver) + } + + if (term.isNotBlank()) { + LocalCache.findUsersStartingWith(term, account) } else { - localResults + emptyList() } }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) @@ -159,6 +208,35 @@ class SearchBarViewModel( }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + val relayResults = + combine( + searchValueFlow.debounce(100), + invalidations, + ) { term, version -> + if (term.length > 1) { + val isTypingRelay = term.length > 7 && (term.startsWith("wss://") || term.startsWith("ws://")) + val relayUrl = + if (isTypingRelay) { + runCatching { NormalizedRelayUrl(Rfc3986.normalize(term)) }.getOrNull() + } else { + null + } + val lower = term.lowercase() + + val relays = + listOfNotNull(relayUrl) + + LocalCache.relayHints.relayDB.filter { _, relay -> relay.url.contains(lower) } + + relays + .map { relaySetupInfoBuilder(it) } + .sortedByDescending { it.relayStat.receivedBytes } + .take(20) + } else { + emptyList() + } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) + override val isRefreshing = derivedStateOf { searchValue.isNotBlank() } override fun invalidateData(ignoreIfDoing: Boolean) { @@ -173,11 +251,12 @@ class SearchBarViewModel( fun clear() = updateSearchValue("") - fun updateDataSource(searchTerm: String) { + suspend fun updateDataSource(searchTerm: String) { if (searchTerm.isBlank()) { searchDataSourceState.searchQuery.tryEmit("") } else { searchDataSourceState.searchQuery.tryEmit(searchTerm) + listState.scrollToItem(0, 0) } } @@ -185,8 +264,9 @@ class SearchBarViewModel( class Factory( val account: Account, + val nip05: INip05Client, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = SearchBarViewModel(account) as T + override fun create(modelClass: Class): T = SearchBarViewModel(account, nip05) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index e9fd07139..68d952c01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -31,9 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider @@ -54,6 +52,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -70,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -91,6 +91,7 @@ fun SearchScreen( factory = SearchBarViewModel.Factory( accountViewModel.account, + accountViewModel.nip05Client, ), ) @@ -105,16 +106,16 @@ fun SearchScreen( ) { WatchLifecycleAndUpdateModel(searchBarViewModel) - val listState = rememberLazyListState() - LaunchedEffect(searchBarViewModel.focusRequester) { - searchBarViewModel.focusRequester.requestFocus() + if (searchBarViewModel.listState.firstVisibleItemIndex == 0) { + searchBarViewModel.focusRequester.requestFocus() + } } DisappearingScaffold( isInvertedLayout = false, topBar = { - SearchBar(searchBarViewModel, listState, accountViewModel, nav) + SearchBar(searchBarViewModel, accountViewModel, nav) }, bottomBar = { AppBottomBar(Route.Search, accountViewModel) { route -> @@ -127,7 +128,7 @@ fun SearchScreen( modifier = Modifier.padding(it).consumeWindowInsets(it), ) { ObserveRelayListForSearchAndDisplayIfNotFound(accountViewModel, nav) - DisplaySearchResults(searchBarViewModel, listState, nav, accountViewModel) + DisplaySearchResults(searchBarViewModel, nav, accountViewModel) } } } @@ -136,7 +137,6 @@ fun SearchScreen( @Composable private fun SearchBar( searchBarViewModel: SearchBarViewModel, - listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, ) { @@ -160,23 +160,9 @@ private fun SearchBar( } } - AnimateOnNewSearch(searchBarViewModel, listState) - SearchTextField(searchBarViewModel, Modifier.statusBarsPadding()) } -@Composable -fun AnimateOnNewSearch( - searchBarViewModel: SearchBarViewModel, - listState: LazyListState, -) { - val searchTerm by searchBarViewModel.searchTerm.collectAsStateWithLifecycle() - - LaunchedEffect(searchTerm) { - listState.animateScrollToItem(0) - } -} - @Composable private fun SearchTextField( searchBarViewModel: SearchBarViewModel, @@ -233,7 +219,6 @@ private fun SearchTextField( @Composable private fun DisplaySearchResults( searchBarViewModel: SearchBarViewModel, - listState: LazyListState, nav: INav, accountViewModel: AccountViewModel, ) { @@ -242,6 +227,7 @@ private fun DisplaySearchResults( } val hashTags by searchBarViewModel.hashtagResults.collectAsStateWithLifecycle() + val relays by searchBarViewModel.relayResults.collectAsStateWithLifecycle() val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle() val publicChatChannels by searchBarViewModel.searchResultsPublicChatChannels.collectAsStateWithLifecycle() val ephemeralChannels by searchBarViewModel.searchResultsEphemeralChannels.collectAsStateWithLifecycle() @@ -251,7 +237,7 @@ private fun DisplaySearchResults( LazyColumn( modifier = Modifier.fillMaxHeight(), contentPadding = FeedPadding, - state = listState, + state = searchBarViewModel.listState, ) { itemsIndexed( hashTags, @@ -276,6 +262,23 @@ private fun DisplaySearchResults( ) } + itemsIndexed( + relays, + key = { _, item -> "relay${item.relay.url}" }, + ) { _, relayInfo -> + BasicRelaySetupInfoClickableRow( + item = relayInfo, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + onClick = { nav.nav(Route.RelayInfo(relayInfo.relay.url)) }, + onDelete = null, + nip11CachedRetriever = Amethyst.instance.nip11Cache, + modifier = Modifier.padding(vertical = 5.dp, horizontal = 10.dp), + accountViewModel = accountViewModel, + nav = nav, + ) + } + itemsIndexed( publicChatChannels, key = { _, item -> "public" + item.idHex }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7d2bcf860..4dcf56f79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -41,8 +41,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -148,6 +146,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.PrivacyOptions) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.namecoin_settings, + icon = Icons.Outlined.Security, + tint = tint, + onClick = { nav.nav(Route.NamecoinSettings) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.ui_preferences, icon = Icons.Outlined.Settings, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt new file mode 100644 index 000000000..870570194 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -0,0 +1,81 @@ +/* + * 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.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +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 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 kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NamecoinSettingsScreen( + namecoinPrefs: NamecoinSharedPreferences, + nav: INav, +) { + val namecoinSettings by namecoinPrefs.settings.collectAsState() + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.namecoin_settings), nav::popBack) + }, + ) { + Column( + Modifier + .padding(it) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp), + ) { + NamecoinSettingsSection( + settings = namecoinSettings, + onToggleEnabled = { enabled -> + scope.launch { namecoinPrefs.setEnabled(enabled) } + }, + onAddServer = { server -> + scope.launch { namecoinPrefs.addServer(server) } + }, + onRemoveServer = { server -> + scope.launch { namecoinPrefs.removeServer(server) } + }, + onReset = { + scope.launch { namecoinPrefs.reset() } + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt new file mode 100644 index 000000000..db747e138 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -0,0 +1,401 @@ +/* + * 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.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.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.service.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS + +/** + * Complete settings section for Namecoin ElectrumX server configuration. + * + * Designed to sit in the Privacy / Settings screen alongside existing + * Tor settings. + * + * @param settings Current [NamecoinSettings] state + * @param onToggleEnabled Called when user toggles the master switch + * @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 + */ +@Composable +fun NamecoinSettingsSection( + settings: NamecoinSettings, + onToggleEnabled: (Boolean) -> Unit, + onAddServer: (String) -> Unit, + onRemoveServer: (String) -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(16.dp)) { + // ── Section header ───────────────────────────────────────── + SectionHeader(enabled = settings.enabled, onToggle = onToggleEnabled) + + AnimatedVisibility( + visible = settings.enabled, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(12.dp)) + + // ── Explanation ───────────────────────────────────── + Text( + "Namecoin names (.bit, d/, id/) are resolved via ElectrumX servers. " + + "By default, public community servers are used. " + + "For maximum privacy, add your own server below — when custom " + + "servers are set, the defaults are completely ignored.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + // ── Active servers display ───────────────────────── + ActiveServersDisplay(settings = settings) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(12.dp)) + + // ── Custom servers list ──────────────────────────── + CustomServersList( + servers = settings.customServers, + onRemove = onRemoveServer, + ) + + // ── Add server input ─────────────────────────────── + AddServerInput(onAdd = onAddServer) + + Spacer(Modifier.height(8.dp)) + + // ── Reset button ─────────────────────────────────── + if (settings.hasCustomServers) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onReset) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Reset to defaults") + } + } + } + } + } + } +} + +// ── Sub-composables ──────────────────────────────────────────────────── + +@Composable +private fun SectionHeader( + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Lock, + contentDescription = null, + tint = Color(0xFF4A90D9), // Namecoin blue + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + Column { + Text( + "Namecoin Resolution", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Blockchain identity lookups (.bit)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = enabled, + onCheckedChange = onToggle, + ) + } +} + +@Composable +private fun ActiveServersDisplay(settings: NamecoinSettings) { + val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + val isCustom = settings.hasCustomServers + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Active servers", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + if (isCustom) { + Text( + "CUSTOM", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF4A90D9), + modifier = + Modifier + .background( + Color(0xFF4A90D9).copy(alpha = 0.1f), + RoundedCornerShape(4.dp), + ).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } else { + Text( + "DEFAULT", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(6.dp)) + servers.forEach { server -> + ServerRow( + displayText = + "${server.host}:${server.port}" + + if (!server.useSsl) " (tcp)" else " (tls)", + isActive = true, + ) + } + } +} + +@Composable +private fun CustomServersList( + servers: List, + onRemove: (String) -> Unit, +) { + if (servers.isEmpty()) { + Text( + "No custom servers configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + Text( + "Custom servers (used exclusively)", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 4.dp), + ) + servers.forEach { server -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = server, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { onRemove(server) }, + modifier = Modifier.size(28.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove server", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun AddServerInput(onAdd: (String) -> Unit) { + var input by rememberSaveable { mutableStateOf("") } + var validationError by remember { mutableStateOf(null) } + val kb = LocalSoftwareKeyboardController.current + + fun tryAdd() { + val trimmed = input.trim() + if (trimmed.isBlank()) { + validationError = "Enter a server address" + return + } + val parsed = NamecoinSettings.parseServerString(trimmed) + if (parsed == null) { + validationError = "Invalid format. Use host:port or host:port:tcp" + return + } + validationError = null + onAdd(trimmed) + input = "" + kb?.hide() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + OutlinedTextField( + value = input, + onValueChange = { + input = it + validationError = null + }, + label = { Text("Add ElectrumX server") }, + placeholder = { Text("host:port or host:port:tcp") }, + singleLine = true, + isError = validationError != null, + supportingText = + validationError?.let { err -> + { Text(err, color = MaterialTheme.colorScheme.error) } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { tryAdd() }), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { tryAdd() }, + modifier = + Modifier + .padding(top = 8.dp) + .size(40.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + RoundedCornerShape(8.dp), + ), + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add server", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun ServerRow( + displayText: String, + isActive: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "•", + fontSize = 10.sp, + color = + if (isActive) { + Color(0xFF2E8B57) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(end = 6.dp), + ) + Text( + text = displayText, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt index 968b6beff..e995b1377 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,10 +33,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.DragIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -56,17 +57,32 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ReactionRowAction import com.vitorpamplona.amethyst.model.ReactionRowItem +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +@Composable +@Preview(device = "spec:width=2100px,height=2340px,dpi=440") +fun ReactionsSettingsScreenPreview() { + ThemeComparisonRow { + ReactionsSettingsScreen( + mockAccountViewModel(), + EmptyNav(), + ) + } +} @Composable fun ReactionsSettingsScreen( @@ -87,7 +103,7 @@ fun ReactionsSettingsScreen( @Composable fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateWithLifecycle() - var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toMutableList()) } + var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toList()) } fun save(newItems: List) { items = newItems.toMutableList() @@ -97,13 +113,14 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { var draggedItemIndex by remember { mutableIntStateOf(-1) } var dragOffset by remember { mutableFloatStateOf(0f) } val itemHeights = remember { mutableStateMapOf() } + val isDragging = draggedItemIndex >= 0 + val scrollState = remember { ScrollState(0) } Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = Size20dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(scrollState, enabled = !isDragging), ) { Spacer(modifier = Modifier.height(16.dp)) @@ -111,7 +128,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { text = stringRes(R.string.reactions_settings_description), style = MaterialTheme.typography.bodyMedium, color = Color.Gray, - modifier = Modifier.padding(bottom = 16.dp), + modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp), ) items.forEachIndexed { index, item -> @@ -206,7 +223,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { .zIndex(if (isDragging) 1f else 0f), ) if (index < items.lastIndex) { - HorizontalDivider() + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) } } @@ -238,16 +255,25 @@ private fun ReactionRowItemCard( .fillMaxWidth() .onGloballyPositioned { coordinates -> onMeasured(coordinates.size.height.toFloat()) - } - .graphicsLayer { + }.graphicsLayer { translationY = dragOffsetY shadowElevation = elevation if (isDragging) { scaleX = 1.02f scaleY = 1.02f } - } - .padding(vertical = 8.dp), + }.padding(vertical = 8.dp, horizontal = Size20dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -270,25 +296,17 @@ private fun ReactionRowItemCard( ) } - Icon( - Icons.Default.DragHandle, - contentDescription = stringRes(R.string.reactions_settings_reorder), - modifier = - Modifier - .size(32.dp) - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { onDragStart() }, - onDrag = { change, dragAmount -> - change.consume() - onDrag(dragAmount.y) - }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragCancel() }, - ) - }, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box( + modifier = Modifier.size(32.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + Icons.Default.DragIndicator, + contentDescription = stringRes(R.string.reactions_settings_reorder), + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Spacer(modifier = Modifier.height(8.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 5b6f0cdbb..21760c45e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -376,7 +376,7 @@ fun MutedWordActionOptions( ) { val isMutedWord by observeAccountIsHiddenWord(accountViewModel.account, word) - if (isMutedWord == true) { + if (isMutedWord) { ShowWordButton { if (!accountViewModel.isWriteable()) { accountViewModel.toastManager.toast( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index fac25dca4..62e5657f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -124,12 +124,12 @@ import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList -import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayRelayFeedsList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList @@ -138,8 +138,11 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile +import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent @@ -222,11 +225,13 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -244,6 +249,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -528,6 +534,7 @@ private fun FullBleedNoteCompose( is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) + is CodeSnippetEvent -> RenderCodeSnippetHeaderForThread(noteEvent) } Row( @@ -622,8 +629,8 @@ private fun FullBleedNoteCompose( DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is TrustedRelayListEvent) { DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is FavoriteRelayListEvent) { - DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is RelayFeedsListEvent) { + DisplayRelayFeedsList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is IndexerRelayListEvent) { DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BroadcastRelayListEvent) { @@ -635,12 +642,12 @@ private fun FullBleedNoteCompose( } else if (noteEvent is InteractiveStoryBaseEvent) { RenderInteractiveStory( baseNote, - false, - true, - 3, - backgroundColor, - accountViewModel, - nav, + makeItShort = false, + canPreview = true, + quotesLeft = 3, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, ) } else if (noteEvent is GitPatchEvent) { RenderGitPatchEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) @@ -654,6 +661,10 @@ private fun FullBleedNoteCompose( RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is PublicMessageEvent) { RenderPublicMessage(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is CalendarTimeSlotEvent) { + RenderCalendarTimeSlotEvent(baseNote, accountViewModel, nav) + } else if (noteEvent is CalendarDateSlotEvent) { + RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) } else if (noteEvent is CommentEvent) { RenderTextEvent( baseNote, @@ -842,7 +853,7 @@ private fun RenderClassifiedsReaderForThread( Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { Column { - if (imageSet != null && imageSet.isNotEmpty()) { + if (!imageSet.isNullOrEmpty()) { AutoNonlazyGrid(imageSet.size) { ZoomableContentView( content = imageSet[it], diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index f1bd3b144..f51773ef6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -97,6 +97,7 @@ val HalfDoubleVertSpacer = Modifier.height(7.dp) val Size0dp = 0.dp val Size2dp = 2.dp +val Size3dp = 3.dp val Size5dp = 5.dp val Size6dp = 6.dp val Size8dp = 8.dp @@ -125,6 +126,7 @@ val Size100dp = 100.dp val Size110dp = 110.dp val Size165dp = 165.dp +val StdEndPadding = Modifier.padding(end = 10.dp) val HalfEndPadding = Modifier.padding(end = 5.dp) val HalfStartPadding = Modifier.padding(start = 5.dp) val StdStartPadding = Modifier.padding(start = 10.dp) @@ -374,6 +376,7 @@ val PaddingHorizontal12Modifier = Modifier.padding(horizontal = 12.dp) val QuickActionPopupShadow = Modifier.shadow(elevation = Size6dp, shape = SmallestBorder) val SpacedBy2dp = Arrangement.spacedBy(Size2dp) +val SpacedBy3dp = Arrangement.spacedBy(Size3dp) val SpacedBy5dp = Arrangement.spacedBy(Size5dp) val SpacedBy10dp = Arrangement.spacedBy(Size10dp) val SpacedBy55dp = Arrangement.spacedBy(Size55dp) @@ -382,4 +385,7 @@ val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp) val Size50ModifierOffset10 = Modifier.size(50.dp).offset(y = (-10).dp) +val SuggestionListDefaultHeightChat = Modifier.heightIn(0.dp, 200.dp) +val SuggestionListDefaultHeightPage = Modifier.heightIn(0.dp, 300.dp) + val FollowPackHeaderModifier = Modifier.fillMaxWidth().height(TopBarSize) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 4850ce01d..63ca32e91 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -7,8 +7,8 @@ Skenovat QR kód Přesto zobrazit Tento příspěvek byl skryt, protože zmiňuje vaše skryté uživatele nebo slova - Příspěvek byl označen jako nevhodný uživatelem - Příspěvek nenalezen + Příspěvek byl ztlumený nebo nahlášen + Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací 👀 Obrázek kanálu Odkazovaná událost nebyla nalezena @@ -158,6 +158,7 @@ Video uloženo do video galerie telefonu Nepodařilo se uložit video Nahrát obrázek + Nahrát soubor Pořiďte fotografii Nahrát video Nahrajte zprávu @@ -419,6 +420,15 @@ Tajemství Wallet Connect Zobrazit tajný klíč nsec / hex soukromý klíč + Připojeno + Nepřipojeno + Pokročilé: zadejte podrobnosti připojení ručně + Rychlé částky Zap + Zobrazí se při stisknutí tlačítka zap. Klepnutím na částku ji odeberete. Pokud ponecháte prázdné, otevře se pokaždé dialog pro zadání částky. + Soukromí Zap + Určuje, jak je zobrazena vaše identita při odesílání zapsu. + Připojit peněženku + Zobrazit kanál relé Částka záruky v sats Zveřejnit hlasování Povinná pole: @@ -658,6 +668,7 @@ Množství bajtů, které bylo přijato z tohoto relé, včetně filtrů a událostí Při pokusu o získání informací z Relay se vyskytla chyba z %1$s Vlastník + Používáno Servisní klíč Spuštěno %1$s Spuštěno %1$s (%2$s) @@ -853,6 +864,7 @@ Kdy načíst obrázek Kopírovat zásobník Kopírovat do schránky + Zkopírovat nprofile do schránky Kopírovat npub do schránky Sdílet nebo Uložit Kopírovat URL do schránky @@ -1042,6 +1054,21 @@ Olajkovat Zap Změnit rychlé reakce + Nastavení reakcí + Nastavte, která tlačítka reakcí se zobrazují, jejich pořadí a zda se mají zobrazovat počty. + Povoleno + Zobrazit počet + Změnit pořadí + Odpovědět + Odpovědět na tuto poznámku + Boost + Repostovat nebo citovat tuto poznámku + To se mi líbí + Reagovat na tuto poznámku pomocí emoji + Zap + Poslat lightning platbu autorovi + Sdílet + Sdílet tuto poznámku externě Obrázek profilu %1$s Relé %1$s Rozbalit seznam relací @@ -1061,6 +1088,7 @@ Přidat upozornění na obsah Odstranit upozornění na obsah Zobrazit npub jako QR kód + Zobrazit nprofile jako QR kód Neplatná adresa Amethyst obdržel URI k otevření, ale tento URI byl neplatný: %1$s Relé DM Schránky diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index d1fa799fd..6d351d365 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -160,6 +160,7 @@ erie gespeichert Video in der Videogalerie des Telefons gespeichert Video konnte nicht gespeichert werden Bild hochladen + Datei hochladen Ein Foto aufnehmen Video aufnehmen Eine Nachricht aufnehmen @@ -425,6 +426,15 @@ anz der Bedingungen ist erforderlich Wallet-Verbindungs-Geheimnis Geheimen Schlüssel anzeigen nsec / hexadezimaler privater Schlüssel + Verbunden + Nicht verbunden + Erweitert: Verbindungsdetails manuell eingeben + Schnelle Zap-Beträge + Wird beim Drücken der Zap-Schaltfläche angezeigt. Tippe auf einen Betrag, um ihn zu entfernen. Wenn du es leer lässt, wird jedes Mal ein Dialog zur Eingabe eines Betrags geöffnet. + Zap-Datenschutz + Legt fest, wie deine Identität beim Senden eines Zaps angezeigt wird. + Wallet verbinden + Relay-Feed anzeigen Spendenbetrag in Sats Umfrage veröffentlichen Erforderliche Felder: @@ -663,6 +673,7 @@ anz der Bedingungen ist erforderlich Die Menge in Bytes, die von diesem Relais empfangen wurde, einschließlich Filter und Ereignisse Ein Fehler ist beim Abrufen von Relay-Informationen von %1$s aufgetreten Inhaber + Verwendet von Service-Schlüssel %1$s wird ausgeführt %1$s wird ausgeführt (%2$s) @@ -858,6 +869,7 @@ anz der Bedingungen ist erforderlich Wann Bilder geladen werden sollen Stapel kopieren In Zwischenablage kopieren + nprofile in die Zwischenablage kopieren Npub in Zwischenablage kopieren Teilen oder Speichern URL in die Zwischenablage kopieren @@ -1047,6 +1059,21 @@ anz der Bedingungen ist erforderlich Gefällt mir Zap Schnelle Reaktionen ändern + Reaktions-Einstellungen + Konfigurieren Sie, welche Reaktionsschaltflächen angezeigt werden, ihre Reihenfolge und ob Zähler angezeigt werden sollen. + Aktiviert + Anzahl anzeigen + Neu anordnen + Antworten + Auf diese Notiz antworten + Boost + Diese Notiz reposten oder zitieren + Gefällt mir + Mit einem Emoji auf diese Notiz reagieren + Zap + Eine Lightning-Zahlung an den Autor senden + Teilen + Diese Notiz extern teilen Profilbild von %1$s %1$s weiterleiten Rela-Liste erweitern @@ -1066,6 +1093,7 @@ anz der Bedingungen ist erforderlich Inhaltswarnung hinzufügen Inhaltswarnung entfernen Npub als QR-Code anzeigen + nprofile als QR-Code anzeigen Ungültige Adresse Amethyst hat eine URI zum Öffnen erhalten, aber diese URI war ungültig: %1$s DM Posteingangs-Relais diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index e75747639..eb90843ab 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -150,6 +150,7 @@ Pronoms Adresse LN URL LN (obsolete) + Enregistrer sur le téléphone Enregistrer dans la Galerie Image enregistrée dans la galerie Le téléchargement de la vidéo a démarré… @@ -158,6 +159,7 @@ Vidéo enregistrée dans la galerie vidéo du téléphone Impossible d\'enregistrer la vidéo Charger une image + Téléverser un fichier Prendre une photo Enregistrer une vidéo Enregistrer un message @@ -421,11 +423,18 @@ Secret Wallet Connect Montrer la clé secrète Clé privée nsec / hex + Connecté + Non connecté + Avancé : saisissez manuellement les détails de la connexion + Confidentialité des Zaps + Contrôle comment votre identité est affichée lorsque vous envoyez un zap. + Connecter le portefeuille Montant engagé en Sats Message Sondage Champs requis: Bénéficiaires du Zap Description du sondage primaire… + Option Option %s Description de l\'option du Sondage Champs optionnels : @@ -433,6 +442,8 @@ Zap maximum Consensus (0–100)% + Date & heure de clôture du sondage + Le sondage se termine dans %1$s Clôturer après jours Impossible de voter @@ -440,6 +451,7 @@ Montant des Zap Un seul vote par utilisateur est autorisé sur ce type de sondage "Recherche de l'Événement %1$s" + Envoyer Zap Ajouter un message public Ajouter un message privé Ajouter un message facture @@ -507,6 +519,7 @@ Suivre via Proxy Autour de moi Général + Échecs Liste en Sourdine Listes de suivi Ce sont des listes de suivi conçues pour votre propre utilisation. Vous pouvez suivre des utilisateurs en privé ou publiquement @@ -656,6 +669,7 @@ Le nombre d\'octets qui ont été reçus depuis ce relais, y compris les filtres et les événements Une erreur s\'est produite en récupérant les informations du relay depuis %1$s Propriétaire + Utilisé par Clé de service Exécution %1$s Exécution %1$s (%2$s) @@ -685,6 +699,12 @@ Termes & Conditions N/A Erreurs et Notifications de ce Relais + Abonnements actifs + %1$d auteurs + %1$d ids + depuis %1$s + jusqu\'à %1$s + limite %1$d Longueur du message Abonnements Filtres @@ -743,9 +763,14 @@ Cette communauté n\'a pas de description. Parlez au propriétaire pour en ajouter une Contenu sensible Ajouter un avertissement de contenu sensible avant de montrer ce contenu + Préférences de l\'UI Préférences de l\'application Préférences utilisateur + Traductions + Réactions Paramètres + Paramètres du compte + Paramètres de l\'application Toujours Wifi uniquement WiFi illimité @@ -775,6 +800,9 @@ Spammeurs Silencieux. Cliquer pour réactiver le son Son activé. Cliquer pour mettre en silencieux + Revenir %d secondes en arrière + Avancer de %d secondes + Image dans l\'image Recherche d\'enregistrements locaux et distants L\'adresse Nostr a été vérfiée La vérification de l\'adresse Nostr a échouée @@ -823,6 +851,7 @@ Quand charger les images Copier dans le presse-papiers Copier dans le presse-papiers + Copier le nprofile dans le presse-papiers Copier npub dans le presse-papiers Partager ou Enregistrer Copier l\'URL dans le presse-papiers @@ -998,6 +1027,7 @@ Notifications Général Vidéos courtes + Échecs Filtres de Sécurité Nouveau Message Nouveaux Shorts : images ou vidéos @@ -1011,6 +1041,19 @@ J\'aime Zap Modifier les réactions rapides + Activé + Afficher le nombre + Réorganiser + Répondre + Répondre à cette note + Boost + Republier ou citer cette note + Aimer + Réagir à cette note avec un émoji + Zap + Envoyer un paiement lightning à l\'auteur + Partager + Partager cette note à l\'extérieur Photo de Profil de %1$s Relais %1$s Développer la liste des relais @@ -1030,6 +1073,7 @@ Ajouter un avertissement de contenu Retirer l\'avertissement de contenu Afficher npub en tant que QR code + Afficher le nprofile en tant que code QR Adresse invalide Amethyst a reçu une URI à ouvrir mais cette URI était invalide : %1$s Relais de boîte de réception MP @@ -1126,6 +1170,7 @@ Punaise Scanner le QR code Accéder au fournisseur de portefeuille tiers Alby + Sélectionner la date Il n\'est pas possible de répondre à un brouillon Il n\'est pas possible de citer un brouillon Il n\'est pas possible de réagir à un brouillon @@ -1191,6 +1236,7 @@ Le relais auquel tous les utilisateurs de ce chat se connectent Partager l\'image… Impossible de partager l\'image, veuillez réessayer plus tard… + Partager la vidéo… Impossible de partager l\'image, veuillez réessayer plus tard… Téléchargement de la vidéo… Hashtag de recherche : #%1$s @@ -1229,9 +1275,94 @@ Kinds Appuyer pour afficher les détails %1$s envoyé + Diffuser les résultats Diffusions (%1$d) Réduire Agrandir +%1$d diffusions supplémentaires + Délai expiré + Nouvelle tentative… + Réessayer + Afficher + Fermer + %1$d/%2$d + Diffusion + Diffusion de %1$s + Diffusion de %1$d événements… + %1$d événements envoyés + Certains événements ont échoué + Tous les événements ont réussi + Tous les événements ont échoué + Réaction + Message vocal + Réponse vocale + Confirmer + Suivant + Ajouter un bouton d\'option de sondage + Retour + Approuver + Connecté + Preuve sociale + Valider + Redémarrer + Accepter + Refuser + Nouveau jeu + Nouvelle partie d\'échecs + Chargement de la partie\u2026 + Partie introuvable + Cette partie a pu se terminer ou est en attente de l\'adversaire. + ID de la partie : %1$s\u2026 + et %1$d autres + Supprimer %1$s + Applications + Recommandations d\'applications + Paramètres utilisateur + Piste audio + Relais bloqués + Calendrier + Rendez-vous + Jeux d\'échecs + Commentaires + Suppressions + Brouillons + Packs d\'émojis + Liste des packs d\'émoji + Chat éphémère + Salles de discussion éphémères + Données médicales + Diffusions en direct + Zaps + Requête NWC + Réponse NWC + Zaps privés + Blogs + Profil + Liste en sourdine + Nostr Connect + Listes de personnes + Photos + Sondage + Relais privés + Message public + Réactions + Relais de recherche + Statut utilisateur + Notes + Modifications + Torrents + Vidéos + Vidéos courtes + Message vocal + Réponse vocale + Wiki + Sélectionnez les utilisateurs à suivre + Profil à importer depuis + recherche, npub1…, alice@exemple.com + Rechercher un autre + Importer plus + Continuer + Ignorer pour le moment + Tout sélectionner diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 27c0d2748..e733a9483 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -421,6 +421,14 @@ धनकोष संयोजन रहस्य गुप्त कुंचिका दिखाएँ एनसेक॰ / षोडशांकरूप निजी कुंचिका + संयोजित + असंयोजित + उन्नत : संयोजन विवरणों को स्वयम प्रविष्ट करें + शीघ्र ज्साप संख्याएँ + ज्साप घुण्डी दबाने पर दिखाया जाता है। एक संख्या दबाएँ उसे हटाने के लिए। उसे रिक्त छोड दें तो प्रत्येक बार संख्या प्रविष्ट करने के लिए पूछेगा। + ज्साप गोपनीयता + नियन्त्रण करता है कि आपका परिचय कैसे दिखाया जाता है ज्साप भेजने पर। + संयोजन धनकोष प्रतिज्ञा मात्रा साट्स में मतदान प्रकाशित करें अनिवार्य प्रपत्रस्थान : @@ -443,6 +451,7 @@ ज्साप मात्रा प्रत्येक उपयोगकर्ता को केवल एक निर्वाचन करने की अनुमति है इस प्रकार के मतदान में। "घटना %1$s के लिए खोज चल रहा है" + ज्साप भेजें सार्वजनिक संदेश जोडें निजी संदेश जोडें चालान संदेश जोडें @@ -690,6 +699,17 @@ नियम तथा अवस्था अनुपलब्ध अपक्रम तथा सूचनाएँ इस पुनःप्रसारक से + सक्रिय ग्राहकताएँ + अपूर्ण निर्गतपेटिका घटनाएँ + अनुरोध ग्राहकताएँ (%1$d) + गणना ग्राहकताएँ (%1$d) + निर्गतपेटिका घटनाएँ (%1$d) + इस पुनःप्रसारक के लिए कोई सक्रिय ग्राहकताएँ नहीं + %1$d लेखक + %1$d विभेदक + %1$s से + %1$s तक + सीमा %1$d संदेश लम्बाई ग्राहकताएँ छलनियाँ @@ -749,9 +769,14 @@ इस समुदाय का कोई विवरण नहीं। इसके अधिपति से बात करें इसे जोडने के लिए संवेदनशील विषयवस्तु इसे दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है + प्रयोगमाध्यम आद्यताएँ क्रमक आद्यताएँ उपयोगकर्ता आद्यताएँ + अनुवाद + प्रतिक्रियाएँ स्थापना विकल्प + लेखा स्थापना विकल्प + क्रमक स्थापना विकल्प सर्वदा केवल वै॰फै॰ मात्रांकन रहित वैफै॰ @@ -781,6 +806,9 @@ कचरालेख प्रेषक मौन किया गया। अमौन करने के लिए टाँकें ध्वनि सक्रिय। मौन करने के लिए टाँकें + %d सेकण्ड पूर्व जाएँ + %d सेकण्ड आगे जाएँ + चित्र में चित्र स्थानीय तथा दूरस्थ अभिलेखों को खोजें नोस्ट्र पता का सत्यापन सफल नोस्ट्र पता का सत्यापन असफल @@ -1027,6 +1055,21 @@ इष्ट ज्साप शीघ्र प्रतिक्रियाओं का परिवर्तन करें + प्रतिक्रिया पंक्ति + समाकृति करें कि किन प्रतिक्रिया घुण्डियाँ दिखाए जाएँगे तथा उनके क्रम तथा यदि संकलन सूचक दिखाए जाएँगे। + सक्षम + संकलन दिखाएँ + पुनःव्यवस्थित करें + उत्तर + इस पत्र के लिए उत्तर लिखें + उद्धृत करें + पत्र पुनःप्रकाशित करें अथवा टीका लिखें + प्रिय + प्रतिक्रिया करें इस पत्र पर चित्राक्षर द्वारा + ज्साप + लेखक को लैटनिंग भुगतान भेजें + बाँटें + इस पत्र को बाहर बाँटें %1$s का परिचय चित्र पुनःप्रसारक %1$s पुनःप्रसारक सूची का विस्तारण करें @@ -1098,6 +1141,9 @@ विश्वसनीय पुनःप्रसारक विश्वसनीय पुनःप्रसारक विश्वसनीय पुनःप्रसारक जिनके लिए टोर संयोजन आवश्यक नहीं + इष्ट पुनःप्रसारक सूचनावलियाँ + इष्ट पुनःप्रसारक सूचनावलियाँ + पुनःप्रसारक जहाँ आप प्रायः जाते हैं उनके वैश्विक सूचनावली देखने के लिए प्रतिनिधि पुनःप्रसारक प्रतिनिधि पुनःप्रसारक संग्रहकर्ता पुनःप्रसारक जो क्रमक द्वारा उपयुक्त होना चाहिए आपके सूचनावलियों का अवरोहण करने के लिए। जैसे कि filter.nostr.wine । यह निर्गतपेटिका प्रतिरूप को प्रतिस्थापित करेगा तथा क्रमक आपके सूचियों में पुनःप्रसारकों के साथ ही जुडेगा। @@ -1143,6 +1189,7 @@ घुण्डी क्यूआर॰ क्रमचित्र परखें तृतीय पक्ष धनकोष प्रदाता आल्बी तक जाएँ + दिनांक का चयन करें एक टीका पाण्डुलिपि को उत्तर नहीं दे सकते एक टीका पाण्डुलिपि पर टीका नहीं लिख सकते एक टीका पाण्डुलिपि पर प्रतिक्रिया नहीं कर सकते @@ -1279,5 +1326,149 @@ पुष्टि करें अगला मतदान विकल्प घुण्डी जोडें + पीछे जाएँ + स्वीकार करें + संयोजित + सामूहिक प्रमाण + उपस्थापन + पुनःआरम्भ + स्वीकार + अस्वीकार + नया खेल + नया चतुरंग खेल + खेल आवहन चालू\u2026 + खेल अप्राप्त + सम्भाव्यतः खेल समाप्त अथवा प्रतिपक्ष की प्रतीक्षा में। + खेल विभेदक : %1$s\u2026 + तथा %1$d अन्य + %1$s हटाएँ + निर्गतपेटिका पुनःप्रसारक + क्रमक + क्रमक अनुशंसाएँ + प्रयोक्ता स्थापना विकल्प + ध्वनि शीर्षक + ध्वनि अभिलेख + पदक पुरस्कार + पदक परिभाषाएँ + परिचय पदक + बाधित पुनःप्रसारक + ब्लोस्सम॰ प्रसारसंगणक + ब्लोस्सम॰ प्रमाणीकरण + प्रसारण पुनःप्रसारक + स्मर्त्तव्य सूची + दिवस नियुक्ति + दिनदर्शिका + नियुक्ति + नियुक्ति प्रत्युत्तर अपेक्षित + चतुरंग खेल + चतुरंग प्रमाणीकरण + इष्ट पुनःप्रसारक + चतुरंग प्रत्याह्वान + चतुरंग खेल स्वीकार + चतुरंग चाल + चतुरंग खेल समाप्त + चतुरंग समस्तर समापन प्रस्ताव + प्रणाली परिभाषा + प्रणाली संदेश छिपाएँ + प्रणाली सूची + प्रणाली संदेश + प्रणाली उपतथ्य + प्रणाली प्रयोक्ता मौन करें + सीधा संदेश अभिलेख + सीधा संदेश + सीधा संदेश पुनःप्रसारक + वर्गीकृत विज्ञापन + टिप्पणियाँ + समूह प्रकार + समूह सूची + समूह पत्र + अनुचरण सूची + मिटाए गए + पाण्डुलिपियाँ + चित्राक्षर पोटलियाँ + चित्राक्षर पोटली सूची + अनभिलिखित चर्चा + अस्थायी चर्चाशालाएँ + अभिलेख शीर्षक + परिचय चित्रालय + अभिलेख सेवासंगणक + अंकीय तथ्याभिलेख + अंकीय अभिलेख शीर्षक + चिकित्सा अभिलेख + अनुचरण पोटलियाँ + पुनःप्रकाशन (१६) + स्थलविभेदक अनुचरित + उपहारकोष + गिट समस्या + गिट परिहार + गिट क्रमलेखकोष + गिट उत्तर + ज्साप उद्देश्य + विषयसूचक अनुचरित + प्रमुखताएँ + एचटीटीपी॰ प्रमाणीकरण + निर्देशिका पुनःप्रसारक सूची + साहसिक प्रस्तावना + साहसिक दृश्य + साहसिक पठन + नाम युक्त स्मर्त्तव्य सूची + सीधी बातचीत + सीधा प्रसारण + ज्साप + नोस्टर धनकोष अनुरोध + नोस्टर धनकोष प्रत्युत्तर + निजी ज्साप + ज्साप अनुरोध + जाललेख + मेलन शाला + शाला उपस्थिति + मेलन स्थल + परिचय + मौन सूची + एनएनएस॰ + निप॰ + नोस्टर संयोजन + डीवीएम॰ स्थिति + डीवीएम॰ विषयवस्तु अनुरोध + डीवीएम॰ विषयवस्तु प्रत्युत्तर + डीवीएम॰ प्रयोक्ता अनुरोध + डीवीएम॰ प्रयोक्ता प्रत्युत्तर + ओटीएस॰ + के लिए भुगतान + व्यक्ति सूचियाँ + चित्र + टाँकाएँ + ज्साप मतदान + मतदान + मतदान प्रतिक्रिया + निप॰-०४ सीधासंदेश + निजी पुनःप्रसारक + प्रतिनिधि पुनःप्रसारक + सार्वजनिक संदेश + प्रतिक्रियाएँ + सम्पर्क पत्र + पुनःप्रसारक प्रमाणीकरण + पुनःप्रसारक खोज + पुनःप्रसारक परिवीक्षक घोषणा + पुनःप्रसारक समुच्चय + सूचनाएँ + पुनःप्रकाशन + प्रयोक्ता मिटाया + प्रमाणचिह्न + खोज पुनःप्रसारक + उपयोगकर्ता स्थिति + टीकाएँ + सम्पादन + उग्रप्रवाह + उग्रप्रवाह टिप्पणियाँ + विश्वसनीय पुनःप्रसारक + विश्वसनीय प्रदाता + चलचित्र (अनु) + छोटे (अनु) + चलचित्र + छोटे + ध्वनि सन्देश + ध्वनि उत्तर + विकि॰ diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 3aeac7fdf..92f57ea00 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -150,6 +150,7 @@ Megszólítás LN-cím LN-webcím (elavult) + Mentés a telefonra Mentés a galériába Kép mentve a képgalériába A videó letöltése megkezdődött… @@ -158,6 +159,7 @@ Videó mentve a videógalériába Nem sikerült menteni a videót Kép feltöltése + Fájl feltöltése Kép készítése Videó rögzítése Hangüzenet rögzítése @@ -421,6 +423,15 @@ Wallet Connect titok Titkos kulcs megjelenítése nsec / hex privát kulcs + Kapcsolódva + Nem kapcsolódott + Speciális: kapcsolat részleteinek kézi megadása + Gyors Zap-összegek + A zap gomb megnyomásakor jelenik meg. Érintse meg az összeget, hogy eltávolítsa. Ha üresen hagyja, akkor minden alkalommal megnyílik a párbeszédablak az összeg beírásához. + Zap adatvédelem + Állítsa be, hogy hogyan jelenjen meg a személyazonossága a zap elküldésekor. + Pénztárca összekapcsolása + Átjátszó hírfolyamának megtekintése Hozzájárulás összege satoshiban Szavazás közzététele Kötelező mezők: @@ -443,6 +454,7 @@ Zap-összeg Az ilyen típusú szavazásokon felhasználónként csak egy szavazat engedélyezett "%1$s esemény keresése" + Zap küldése Nyilvános üzenet hozzáadása Privát üzenet hozzáadása Számlaüzenet hozzáadása @@ -661,6 +673,7 @@ Az átjátszótól kapott bájt-mennyiség, beleértve a szűrőket és eseményeket is Hiba lépett fel, amikor megpróbálta lekérni az átjátszó-információt innen: %1$s Tulajdonos + Használat a következővel: Szolgáltatási kulcs %1$s futtatása %1$s (%2$s) futtatása @@ -690,6 +703,17 @@ Általános szerződési feltételek Nem érhető el Hibák és megjegyzések ettől az átjátszótól + Aktív előfizetések + Függőben lévő kimenő események + Szükséges előfizetések (%1$d) + Előfizetések száma (%1$d) + Kimenő események (%1$d) + Nincsenek aktív előfizetések ehhez az átjátszóhoz + %1$d szerző + %1$d azonosító + ekkortól: %1$s + eddig: %1$s + %1$d korlátozás Üzenethossz Előfizetések Szűrők @@ -749,9 +773,14 @@ Ennek a csoportnak nincs leírása vagy szabályai. Kérje meg a tulajdonosát, hogy adjon hozzá egyet Érzékeny tartalom Érzékeny tartalom-figyelmeztetés hozzáadása a tartalom megjelenítése előtt + Felhasználói felület beállításai Alkalmazásbeállítások Felhasználói beállítások + Nyelvi fordítások + Reakciók Beállítások + Fiókbeállítások + Alkalmazásbeállítások Mindig Csak Wi-Fi-n Korlátlan Wi-Fi @@ -781,6 +810,9 @@ Spamelők Némítva. Koppintson a némitás megszüntetéséhez Hang bekapcsolva. Koppintson a némításhoz + Visszatekerés %d másodperccel + Előretekerés %d másodperccel + Kép a képben Helyi és távoli bejegyzések keresése Nostr-cím hitelesítve lett Nostr-cím hitelesítése sikeretelen @@ -838,6 +870,7 @@ Mikor töltse be a képeket Köteg másolása Másolás a vágólapra + nprofile-kulcs vágólapra másolása Npub másolása a vágólapra Megosztás vagy mentés Webcím másolása a vágólapra @@ -1015,6 +1048,7 @@ Rövidek Sakk Biztonsági szűrők + Követettek importálása Új bejegyzés Új rövidek: képek vagy videók Új közösségi bejegyzés @@ -1027,6 +1061,21 @@ Tetszik Zap Gyors reakciók megváltoztatása + Reakciósor + Állítsa be, hogy mely reakciógombok jelenjenek meg, azok sorrendjét, valamint a számlálók megjelenítését. + Engedélyezve + Számláló megjelenítése + Újrarendezés + Válasz + Válasz erre a bejegyzésre + Megtolás + Ennek a bejegyzésnek a megosztása vagy idézése + Kedvelés + Reagálás erre a bejegyzésre egy hangulatjellel + Zap + Lightning fizetés küldése a szerzőnek + Megosztás + Ennek a bejegyzésnek a megosztása külső alkalmazásban %1$s profilképe %1$s átjátszó Átjátszólista kibontása @@ -1046,6 +1095,7 @@ Tartalmi figyelmeztetés hozzáadása Tartalmi figyelmeztetés eltávolítása Az npub-kulcs megjelenítése QR-kódként + nprofile-kulcs megjelenítése QR-kódként Érvénytelen cím Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s Bejövő átjátszók a privát üzenetekhez @@ -1098,6 +1148,9 @@ Megbízható átjátszók Megbízható átjátszók Az átjátszóknak, amelyekben megbízik, nincs szükségük Tor kapcsolatra a következőhöz: + Kedvenc átjátszó-hírforrások + Kedvenc átjátszó-hírforrások + Átjátszók, amelyeket gyakran felkeres a globális hírfolyamuk megtekintéséhez Proxyzott átjátszók Proxyzott átjátszók Összesítő átjátszók, amelyeket az alkalmazásnak használnia kell a hírfolyamok letöltéséhez, például filter.nostr.wine. Ez helyettesíti a kimeneti modellt, és az alkalmazás csak a listákban szereplő átjátszókhoz fog csatlakozni. @@ -1143,6 +1196,7 @@ Kapcsolatrögzítő (Pushpin) QR-kód beolvasása Harmadik féltől származó pénztárca-szolgáltató felkeresése: Alby + Dátum kiválasztása A piszkozatokra nem lehet válaszolni A piszkozatokat nem lehet idézni A piszkozatokra nem lehet reagálni @@ -1279,5 +1333,174 @@ Megerősítés Következő Szavazati lehetőség hozzáadása + Vissza + Elfogadás + Kapcsolódva + Közösségi megerősítés + Beküldés + Újraindítás + Elfogadás + Elutasítás + Új játék + Új sakkjáték + Játék betöltése\u2026 + Nem található játék + Ez a játék már véget ért, vagy ellenfelet vár. + Játékazonosító: %1$s\u2026 + és %1$d további + %1$s eltávolítása + Kimenő átjátszók + Alkalmazások + Ajánlott alkalmazások + Felhasználói beállítások + Audio-fejléc + Hangsáv + Jelvénydíjak + Jelvények meghatározása + Profiljelvények + Letiltott átjátszók + Blossom kiszolgálók + Blossom-hitelesítés + Közvetítési átjátszók + Könyvjelzőlista + Napi bejegyzés + Naptár + Találkozó + Időpont-visszaigazolás + Sakkjátékok + Sakk-hitelesítés + Kedvenc átjátszók + Sakk-kihívások + Sakkjáték elfogadása + Sakklépés + Sakkjáték vége + Döntetlen felajánlása + Csatorna alapadatai + Üzenet elrejtése a csatornában + Csatornalista + Csatornaüzenet + Csatorna metaadatai + Felhasználó némítása a csatornában + Közvetlen üzenetfájl + Közvetlen üzenet + Közvetlen üzenetátjátszók + Apróhirdetések + Megjegyzések + Közösség alapadatai + Közösségi lista + Közösségi bejegyzés + Követési lista + Törlések + Piszkozatok + Emodzsicsomagok + Emodzsicsomag-lista + Eltűnő csevegés + Eltűnő csevegőszobák + Fájl-fejléc + Profilgaléria + Fájlkiszolgálók + BLOB-adatok + BLOB-fejlécek + Egészségügyi adatok + Követési csomagok + Megosztások (16) + Területi követések + Ajándékcsomagolások + Git hibajegy + Git javítófolt + Git tároló + Git válasz + Zap-célok + Hashtag-követések + Kiemelések + Http-hitelesítés + Indexelő átjátszók listája + Kalandprológus + Kalandjelenet + Kalandos olvasmány + Címkézett könyvjelzők + Élő csevegések + Élő közvetítések + Zap-ek + NWC-kérések + NWC-válasz + Privát zap-ek + Zap-kérés + Blogok + Tárgyalószoba + Szobajelenlét + Megbeszélési helyszín + Profil + Lista némítása + NNS + NIP + Nostr-kapcsolódás + DVM-állapot + DVM-tartalomkérés + DVM-tartalomválasz + DVM-felhasználói kérés + DVM-felhasználói válasz + OTS + fizetés kedvezményezettje + Személylisták + Képek + Rögzítettek + Zap-szavazás + Szavazás + Szavazásválasz + NIP-04 közvetlen üzenetek + Privát átjátszók + Proxyzott átjátszók + Nyílvános üzenet + Reakciók + Névjegykártya + Átjátszóhitelesítés + Átjátszófelfedezés + Átjátszó-figyelő hirdetmény + Átjátszókészlet + Jelentések + Megosztások + Felhasználói törlés + Pecsétek + Keresési átjátszók + Felhasználói állapot + Bejegyzések + Szerkesztések + torentek + Torrent-megjegyzések + Megbízható átjátszók + Megbízható szolgáltatók + Videó (válasz) + Rövidek (válasz) + Videó + Rövidek + Hangüzenet + Hangos válasz + Wiki + Kezdje egy remek hírfolyammal, követve azokat az embereket, akikben megbízik. + Követési lista importálása + Felhasználók kiválasztása a követéshez + Importálandó profil + keresés, npub1…, aliz@pelda.hu + Támogatja az npub, nprofile, NIP-05, hex és namecoin (.bit, d/, id/) formátumokat + Követési lista keresése + Borravaló + %1$d felhasználói fiók megtalálva + %1$d kiválasztva + Feloldva a Namecoinon keresztül + Most %1$d fiókot követ + A hírfolyam elkészült. + Kihagyás + Másik keresése + %1$d felhasználói fiók követése + Továbbiak importálása + Folytatás + Kihagyás csak most + %1$s feloldása… + Követési lista lekérdezése… + Nem találhatók követések + %1$d felhasználói fiók követése… + "Írja be egy barátja vagy közösségi vezető profilját. Használhatja az npub, NIP-05 címüket, vagy egy Namecoin nevet, például aliz@pelda.bit vagy id/aliz a blokklánc által ellenőrzött azonosítókhoz." + Összes kijelölése diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 66051792e..3bf7b6cf6 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -125,6 +125,7 @@ Bajtów Błąd Błędy + Odsetek udanych połączeń z transmiterem Liczba błędów połączenia w tej sesji Główny kanał Kanał wiadomości prywatnych @@ -150,6 +151,7 @@ Zaimki LN Adres Adres URL LN (nieaktualny) + Zapisz w telefonie Zapisz w galerii Zdjęcie zostało zapisane w galerii Rozpoczęto pobieranie wideo… @@ -158,6 +160,7 @@ Film zapisany w galerii filmów Nie udało się zapisać filmu Dodaj zdjęcie + Prześlij plik Zrób zdjęcie Nagraj film Nagraj wiadomość @@ -418,6 +421,15 @@ Klucz dostępu do portfela Pokaż tajny klucz klucz prywatny nsec / hex + Połączony + Nie połączony + Zaawansowane: wprowadź szczegóły połączenia ręcznie + Kwoty Quick Zap + Wyświetlane po naciśnięciu przycisku zap. Dotknij kwoty, aby ją usunąć. Jeśli pozostawisz to pole puste, za każdym razem otworzy się okno dialogowe umożliwiające wprowadzenie kwoty. + Prywatność Zap + Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa. + Podłącz portfel + Przejrzyj kanał transmitera Kwota zobowiązania w Satach Wyślij Ankietę Wymagane pola: @@ -631,6 +643,7 @@ Dołącz Dzisiaj Ostrzeżenie o zawartości + Ostrzeżenie: %1$s Ten post zawiera wrażliwe treści, które niektóre osoby mogą uznać za obraźliwe lub niepokojące Zawsze ukrywaj wrażliwe treści Zawsze pokazuj wrażliwą zawartość @@ -659,6 +672,7 @@ Ilość w bajtach, która została otrzymana z tego transmitera, w tym filtry i wydarzenia Wystąpił błąd podczas próby uzyskania informacji o transmiterze z %1$s Operator + Używany przez Klucz dostępu do usług Uruchamianie %1$s Uruchamianie %1$s (%2$s) @@ -688,7 +702,14 @@ Zasady i warunki użytkowania Nie dotyczy Błędy i powiadomienia z tego transmitera + Aktywne subskrypcje + Wydarzenia oczekujące na wysłanie + REQ Subskrypcje (%1$d) + Subskrypcje KONTA (%1$d) + Wydarzenia Wysłane (%1$d) Brak aktywnych subskrypcji dla tego transmitera + %1$d autorzy + %1$d identyfikatorów od %1$s do %1$s limit %1$d @@ -810,6 +831,7 @@ Pobieranie lokalizacji Brak dostępu do lokalizacji Dodaje ostrzeżenie o wrażliwych treściach przed wyświetleniem treści. Jest to idealne dla dowolnych treści NSFW lub treści, które niektóre osoby mogą uznać za obraźliwe lub przeszkadzające + Powód (Opcjonalne) Nowa Funkcjonalność Aktywacja tego trybu wymaga wysłania wiadomości NIP-17 przez Ametyst. (Prezent, Zapieczętowane wiadomości bezpośrednie i grupowe). NIP-17 jest nowy, a większość klientów jeszcze go nie zaimplementowała. Upewnij się, że odbiorca używa kompatybilnego klienta. Aktywuj @@ -848,6 +870,7 @@ Kiedy załadować obrazki Skopiuj Kopiuj do schowka + Skopiuj nprofile do schowka Kopiuj npub do schowka Udostępnij lub Zapisz Kopiuj adres URL do schowka @@ -1025,6 +1048,7 @@ Filmiki Szachy Filtry bezpieczeństwa + Importuj Obserwujących Nowy post Nowe Króciaki: zdjęcia lub filmiki Nowy wpis w społeczności @@ -1041,6 +1065,7 @@ Skonfiguruj które przyciski reakcji będą wyświetlane, ich kolejność i czy wyświetlić liczniki. Włączone Pokaż licznik + Rekompozycja Odpowiedz Odpowiedz na ten wpis Udostępnij @@ -1070,6 +1095,7 @@ Dodaj ostrzeżenie o treści Usuń ostrzeżenie o treści Pokaż npub jako QR kod + Pokaż nprofile jako kod QR Nieprawidłowy adres Ametyst otrzymał URI do otwarcia, ale ten adres był nieprawidłowy: %1$s Transmitery Skrzynki Odbiorczej @@ -1316,6 +1342,7 @@ Akceptuj Odrzuć Nowa gra + Nowa gra w Szachy Ładowanie gry\u2026 Gra nie znaleziona Ta gra mogła się skończyć lub czeka na przeciwnika. @@ -1325,31 +1352,58 @@ Transmitery wysyłające Aplikacje + Rekomendacje Ustawienia użytkownika Nagłówek audio Ścieżka audio + Odznaki + Definicje odznak Odznaki profilowe Zablokowane Transmitery Serwery Blossom Autentykacja Blossom Transmitery Nadawcze Lista zakładek + Data spotkania Kalendarz + Spotkanie + RSVP Spotkania Ulubione Transmitery + Określenie kanału + Ukryta wiadomość kanału + Lista kanałów + Wiadomość kanału Metadane kanału + Ukryty użytkownik kanału Plik DM Wiadomość DM Transmitery DM Ogłoszenia Komentarze Lista społeczności + Lista obserwowanych Usunięcia Wersje robocze Pakiety emoji Lista Pakietów Emoji + Nagłówek pliku + Galeria profilu Serwery Plików + Dane typu blob + Nagłówki Blob Dane medyczne + Pakiety obserwowanych + Udostępnienia (16) + Obserwacja geohashów + Problem z Git + Łatka Git + Repozytorium Git + Odpowiedź Git + Cele Zap-a + Obserwacja hashtagów + Najważniejsze informacje Indeks listy transmiterów + Opisane zakładki Zapy Zapytanie NWC Odpowiedź NWC @@ -1359,12 +1413,15 @@ Strefa spotkań Profil Lista zablokowanych + NNS NIP Nostr Connect OTS + Płatność dla Listy osób Zdjęcia Pineski + Zap ankiecie Sondaż Odpowiedź w sondażu Wiadomości NIP-04 @@ -1375,10 +1432,12 @@ Wizytówka Autoryzacja transmitera Wyszukiwanie transmiterów + Komunikat monitora transmiterów Zestaw transmiterów Zgłoszenia Powtórzenia Usuń użytkownika + Pieczęć Szukaj Transmiterów Status użytkownika Wpisy @@ -1387,8 +1446,25 @@ Komentarze torrentów Zaufane Transmitery Zaufani dostawcy + Wideo (odpowiedź) + Filmiki (odpowiedź) Wideo Filmiki + Wiadomość głosowa Odpowiedź głosowa Wiki + Stwórz świetny kanał, obserwując te same osoby, które obserwuje zaufana osoba. + Importuj listę obserwowanych + Wybierz profile, które chcesz obserwować + Profil do wczytania z + wyszukaj, npub1…, alicja@domena.pl + Obsługuje npub, nprofile, NIP-05, hex, i namecoin (.bit, d/, id/) + Sprawdź listę obserwowanych + Wybrano: %1$d + Twój kanał jest gotowy. + Pomiń + Pobierz więcej + Kontynuuj + Na razie pomiń + Wybierz Wszystkie diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index f58008471..a444c2d19 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -158,6 +158,7 @@ Vídeo salvo na galeria de vídeos do telefone Falha ao salvar o vídeo Enviar Imagem + Enviar Arquivo Tirar uma foto Gravar um vídeo Gravar uma mensagem @@ -419,6 +420,15 @@ Segredo do Wallet Connect Mostrar chave secreta chave privada nsec / hex + Conectado + Não conectado + Avançado: inserir detalhes de conexão manualmente + Valores Rápidos de Zap + Exibido ao pressionar o botão de zap. Toque em um valor para removê-lo. Se deixar vazio, abrirá o diálogo para inserir um valor todas as vezes. + Privacidade do Zap + Controla como sua identidade é exibida ao enviar um Zap. + Conectar Carteira + Ver feed do Relay Valor em Sats Postar enquete Campos obrigatórios: @@ -658,6 +668,7 @@ A quantidade em bytes que foi recebida deste relé, incluindo filtros e eventos Ocorreu um erro ao tentar obter informações do relay de %1$s Proprietário + Usado por Chave de serviço Executando %1$s Executando %1$s (%2$s) @@ -853,6 +864,7 @@ Quando carregar imagens Copiar a Pilha Copiar para a Área de Transferência + Copiar nprofile para a área de transferência Copiar npub para a área de transferência Compartilhar ou Salvar Copiar URL para a área de transferência @@ -1042,6 +1054,21 @@ Gostar Zap Mudar reações rápidas + Configurações de Reações + Configure quais botões de reação são exibidos, sua ordem e se os contadores devem ser mostrados. + Ativado + Mostrar contagem + Reordenar + Responder + Responder a esta nota + Boost + Repostar ou citar esta nota + Curtir + Reagir a esta nota com um emoji + Zap + Enviar um pagamento lightning ao autor + Compartilhar + Compartilhar esta nota externamente Foto de perfil de %1$s Repassar %1$s Expandir lista de repasse @@ -1061,6 +1088,7 @@ Adicionar aviso de conteúdo Remover aviso de conteúdo Mostrar npub como um código QR + Mostrar nprofile como QR code Endereço inválido O Amethyst recebeu um URI para abrir, mas esse URI era inválido: %1$s Relés da Caixa de Entrada de DM diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index ec57be293..2d63930a8 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -158,6 +158,7 @@ Videon sparad i telefonens videogalleri Det gick inte att spara videon Ladda upp bilden + Ladda upp fil Ta en bild Spela in video Spela in ett meddelande @@ -419,6 +420,15 @@ Wallet Connect hemlighet Visa secret key nsec / hex privat nyckel + Ansluten + Inte ansluten + Avancerat: ange anslutningsinformation manuellt + Snabba Zap-belopp + Visas när du trycker på Zap-knappen. Tryck på ett belopp för att ta bort det. Om du lämnar det tomt öppnas en dialog för att ange ett belopp varje gång. + Zap-sekretess + Styr hur din identitet visas när du skickar ett Zap. + Anslut plånbok + Visa relä-flöde Pantbelopp i Sats Dela Poll Obligatoriska fält: @@ -657,6 +667,7 @@ Mängden data i byte som mottogs från detta relä, inklusive filter och händelser Ett fel inträffade vid försök att hämta information från Relay %1$s Ägare + Används av Service-nyckel Kör %1$s Kör %1$s (%2$s) @@ -852,6 +863,7 @@ När bilder ska laddas Kopiera stacken Kopiera till Urklipp + Kopiera nprofile till urklipp Kopiera npub till urklipp Dela eller Spara Kopiera URL till urklipp @@ -1041,6 +1053,21 @@ Gilla Zap Ändra Snabba Reaktioner + Reaktionsinställningar + Konfigurera vilka reaktionsknappar som visas, deras ordning och om räknare ska visas. + Aktiverad + Visa antal + Ändra ordning + Svara + Svara på denna notis + Boosta + Reposta eller citera denna notis + Gilla + Reagera på denna notis med en emoji + Zap + Skicka en lightning-betalning till författaren + Dela + Dela denna notis externt %1$s Profilbild Relä %1$s Expandera relälistan @@ -1060,6 +1087,7 @@ Lägg till varning för innehåll Ta bort varning för innehåll Visa npub som en QR-kod + Visa nprofile som QR-kod Ogiltig adress Amethyst fick en URI att öppna men den URI var ogiltig: %1$s DM Inkorg Reläer diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index d90248bd8..ddebae33d 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -42,12 +42,12 @@ 举报明确内容 举报非法行为 举报恶意软件 - ???Report Mod??? + 举报 Mod 恶意软件 - ???Mod??? + Mod 你正在使用公钥,公钥是只读的。使用私钥登录以便回复 你正在使用公钥,公钥是只读的。使用私钥登录以便提升帖子 - 使用私钥登录以便点赞帖子 + 你正在使用公钥,公钥是只读的。使用私钥登录以点赞帖子 没有设置打闪金额。长按以更改 你正在使用公钥,公钥是只读的。使用私钥登录以便发送打闪 你正在使用公钥,公钥是只读的。使用私钥登录以便能够关注 @@ -89,7 +89,7 @@ 显示更多 闪电发票 付款 - 闪电小费 + Lightning 小费 给收款方的留言 非常感谢! 聪金额 @@ -125,6 +125,7 @@ 字节 错误 错误 + 中继成功连接的百分比 该会话中产生的连接错误数量 主页 私信 @@ -150,6 +151,7 @@ 称谓代词 闪电地址 LN链接(过期) + 保存到手机 保存到相册 图片已保存到相册 视频下载已开始… @@ -158,6 +160,7 @@ 视频已保存到媒体库 保存视频失败 上传图片 + 上传文件 拍照 录制视频 录制消息 @@ -191,7 +194,7 @@ 会话 Mod 队列 - 动态 + 笔记 回复 你的 相册 @@ -322,7 +325,7 @@ 徽章奖励授予 文本已复制到剪贴板 复制作者的 @npub 到剪贴板 - 复制文章ID (@note1) 到剪贴板 + 已复制笔记ID (@note1) 到剪贴板 选择文本 "<无法解密私密消息>\n\n;你被 %1$s 和 %2$s 之间的私人/加密会话引用。" 添加新账户 @@ -337,7 +340,7 @@ 分享浏览网址 分享 \@提及 - 引用 + 笔记ID 复制 删除 取关 @@ -421,6 +424,15 @@ 钱包绑定私钥 显示私钥 nsec / 十六进制私钥 + 已连接 + 未连接 + 高级:手动输入连接详情 + 快速打闪金额 + 按下打闪按钮时显示。点击接即可删除它。 如果留空,每次都会打开对话框来插入金融。 + 打闪隐私 + 控制发送打闪时如何显示您的身份。 + 连接钱包 + 查看中继源 承诺聪金额 发布投票 必填字段: @@ -443,6 +455,7 @@ 打闪金额 这种投票只允许每个用户一票 "正在查找事件 %1$s" + 发送打闪 添加公开消息 添加私信 添加发票消息 @@ -524,7 +537,7 @@ \n轻按下方刷新,或者轻按“+”按钮新建一个。 新建 - 添加作者到关注集 + 添加作者到关注列表 从列表中添加或删除用户,或用此用户创建一个新列表。 关注集的图标 %1$s 是公开成员 @@ -661,6 +674,7 @@ 从该中继接收事件和过滤响应所使用的数据量 尝试从 %1$s 获取中继器信息时出错 机主 + 使用者 服务密钥 正在运行 %1$s 正在运行 %1$s (%2$s) @@ -690,6 +704,17 @@ 使用条款 N/A 该中继的错误和通知 + 活跃订阅 + 待处理发件箱事件 + 请求订阅(%1$d) + 计算订数 (%1$d) + 发件箱事件(%1$d) + 此中继没有活动订阅 + %1$d 位作者 + %1$d 个 id + 自 %1$s + 直到 %1$s + 限制 %1$d 消息长度 订阅 筛选器 @@ -749,9 +774,14 @@ 此社区没有描述或规则。联系群主来添加 敏感内容 在显示此内容之前添加敏感内容警告 + 用户界面首选项 偏好设置 用户首选项 + 翻译 + 反应 设置 + 账户设置 + 应用程序设置 始终 仅限 WiFi 未计量的 WiFi @@ -781,6 +811,9 @@ 垃圾邮件 静音。点击取消静音 声音开启。点击静音 + 后退 %d 秒 + 前进 %d 秒 + 画中画 搜索本地和远程记录 Nostr 地址已验证 Nostr 地址验证失败 @@ -838,6 +871,7 @@ 何时加载图像 复制 Stack 复制到剪贴板 + 复制 nprofile 到剪贴板 复制 npub 到剪贴板 分享或保存 复制链接到剪贴板 @@ -1015,6 +1049,7 @@ 短篇 国际象棋 安全滤镜 + 导入关注 新帖子 新短篇媒体:图像或视频 新社区笔记 @@ -1027,6 +1062,21 @@ 点赞 打闪 修改快速回应 + 反应设置 + 配置显示的反应按钮、它们的顺序以及是否显示计数器。 + 已启用 + 显示计数 + 调整顺序 + 回复 + 回复此笔记 + Boost + 转发或引用此笔记 + 点赞 + 用表情符号对此笔记进行反应 + 打闪 + 给作者发送 Lightning 网络付款 + 分享 + 对外分享此笔记 %1$s 的个人头像 中继器 %1$s 展开中继列表 @@ -1037,7 +1087,7 @@ 比特币发票 取消比特币发票 取消出售物品 - Zapraiser + 打闪筹款方 取消 Zapraiser 地点 移除地点 @@ -1046,6 +1096,7 @@ 添加内容警告 移除内容警告 将 npub 显示为二维码 + 以二维码显示 nprofile 地址无效 Amethyst 收到了要打开的 URI,但该 uri 无效:%1$s 私信收件箱中继 @@ -1098,6 +1149,9 @@ 可信中继 可信中继 无需使用 Tor 进行连接的可信中继 + 喜爱的中继源 + 喜爱的中继源 + 经常前去查看全局源的中继 代理中继 代理中继 聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。 @@ -1143,6 +1197,7 @@ 图钉 扫描二维码 导航到第三方钱包提供商 Alby + 选择日期 无法回复笔记草稿 无法引用笔记草稿 无法回应笔记草稿 @@ -1279,5 +1334,175 @@ 确认 下一个 添加投票选项按钮 + 返回 + 批准 + 已连接 + 社交证明 + 提交 + 重启 + 接受 + 拒绝 + 新游戏 + 新的国际象棋游戏 + 正在加载游戏\u2026 + 未找到游戏 + 这个游戏可能已经结束或正在等待对手。 + 游戏ID: %1$s\u2026 + 和另外 %1$d + 移除 %1$s + 发件箱中继 + 应用程序 + 应用推荐 + 用户设置 + 音频标题 + 音轨 + 徽章奖励 + 徽章定义 + 个人资料徽章 + 中继黑名单 + Blossom 服务器 + Blossom 认证 + 广播中继 + 书签列表 + 日预约 + 日历 + 预约 + 预约确认请求 + 国际象棋游戏 + Chess 网站认证 + 喜爱的中继 + 国际象棋挑战 + 接受国际象棋游戏挑战 + 棋子移动 + 国际象棋游戏结束 + 和棋请求 + 频道定义 + 频道隐藏消息 + 频道列表 + 频道消息 + 频道元数据 + 频道静音用户 + 私信文件 + 私信消息 + 私信中继 + 分类广告 + 评论 + 社区定义 + 社区列表 + 社区帖子 + 关注列表 + 删除 + 草稿 + 表情包 + 表情包列表 + 临时聊天 + 临时聊天室 + 文件标题 + 资料页图库 + 文件服务器 + Blob 数据 + Blob 头部 + 医疗数据 + 关注包 + 转发(16) + Geohash 关注 + 包装礼物 + Git 问题 + Git 补丁 + Git 仓库 + Git 回复 + 打闪目标 + 话题关注 + 精彩场面 + Http 认证 + 索引中继列表 + 探险前言 + 探险场景 + 探险阅读 + 已命名书签 + 实时聊天 + 直播 + 打闪 + NWC 请求 + NWC 响应 + 私下打闪 + 打闪请求 + 博客 + 会议室 + 房间状态 + 会议空间 + 个人资料 + 静音列表 + NNS + NIP + Nostr 连接 + DVM 状态 + DVM 内容请求 + DVM 内容响应 + DVM 用户请求 + DVM 用户响应 + OTS + PayTo + 人员列表 + 图片 + 置顶 + 打闪投票 + 投票 + 投票响应 + NIP-04 私信 + 私密中继 + 代理中继 + 公开消息 + 反应 + 名片 + 中继认证 + 中继发现 + 中继监视器公告 + 中继集合 + 举报 + 转发 + 用户删除 + 密封信息 + 搜索中继 + 用户状态 + 笔记 + 编辑 + 种子 + 种子注释 + 可信中继 + 可信的提供商 + 视频 (回复) + 短篇 (回复) + 视频 + 短篇 + 语音消息 + 语音回复 + 维基 + 关注你信任的人所关注的人来开启优质的源。 + 导入关注列表 + 选择要关注的用户 + 要导入的配置文件 + 搜索, npub1…, alice@example.com + 支持npub, nprofile, NIP-05, hex, and namecoin (.bit, d, id/) + 查找关注列表 + 提示 + 找到 %1$d 个账户 + 选中了 %1$d 项 + 通过 Namecoin 解析 + 正在关注 %1$d 个账户 + 您的源已经就绪。 + 跳过 + 搜索另一个 + 关注 %1$d 账户 + 导入更多 + 继续 + 暂时跳过 + 正在解析 %1$s… + 正在获取关注列表… + 未找到关注项 + 正在关注 %1$d 个账户… + "输入好友或社区领袖的配置文件。您可以使用他们的 npub、NIP-05地址或Namecoin 名称,例如alice@example.bit 或区块链验证的地址 id/alice。" + 全选 + %1$d%% 运行时间 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d76c47ae2..799135f7d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -137,6 +137,7 @@ Bytes Error Errors + Percentage of successful connections to the relay The number of connection errors in this session Home Feed Private Message Feed @@ -162,6 +163,7 @@ Pronouns LN Address LN URL (outdated) + Save to Phone Save to Gallery Image saved to the phone\'s photo gallery Video download has started… @@ -170,6 +172,7 @@ Video saved to the phone\'s video gallery Failed to save the video Upload Image + Upload File Take a picture Record a video Record a message @@ -448,12 +451,22 @@ Move to Private Wallet Connect Service - Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible + Authorizes Amethyst to pay zaps without leaving the app. Keep the secret safe. Wallet Connect Pubkey Wallet Connect Relay Wallet Connect Secret Show secret key nsec / hex private key + Connected + Not connected + Advanced: enter connection details manually + Quick Zap Amounts + Shown when pressing the zap button. Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. + Zap Privacy + Controls how your identity is shown when you send a zap. + Connect Wallet + + See Relay Feed Pledge Amount in Sats Post Poll @@ -730,6 +743,7 @@ Today Content warning + Warning: %1$s This post contains sensitive content which some people may find offensive or disturbing Always hide sensitive content Always show sensitive content @@ -764,6 +778,7 @@ The amount in bytes that was received from this relay, including filters and events An error occurred trying to get relay information from %1$s Owner + Used By Service Key Running %1$s Running %1$s (%2$s) @@ -948,6 +963,7 @@ No Location Permissions Adds sensitive content warning before showing your content. This is ideal for any NSFW content or content some people may find offensive or disturbing + Reason (optional) New Feature Activating this mode requires Amethyst to send a NIP-17 message (GiftWrapped, Sealed Direct and Group Messages). NIP-17 is new and most clients have not implemented it yet. Make sure the receiver is using a compatible client. @@ -996,6 +1012,7 @@ Copy Stack Copy to clipboard + Copy nprofile to clipboard Copy npub to clipboard Share or Save Copy URL to clipboard @@ -1217,6 +1234,7 @@ Shorts Chess Security Filters + Import Follows New Post New Shorts: images or videos @@ -1234,7 +1252,7 @@ Change Quick Reactions - Reactions Settings + Reaction Row Configure which reaction buttons are shown, their order, and whether to display counters. Enabled Show Count @@ -1274,6 +1292,7 @@ Add content warning Remove content warning Show npub as a QR code + Show nprofile as a QR code Invalid address Amethyst received a URI to open but that uri was invalid: %1$s @@ -1702,4 +1721,31 @@ Voice Msg Voice Reply Wiki + Start with a great feed by following the same people as someone you trust. + Import Follow List + Select Users to Follow + Profile to import from + search, npub1…, alice@example.com + Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/) + Look Up Follow List + Tip + %1$d accounts found + %1$d selected + Resolved via Namecoin + Now following %1$d accounts + Your feed is ready. + Skip + Search Another + Follow %1$d accounts + Import More + Continue + Skip for now + Resolving %1$s… + Fetching follow list… + No follows found + Following %1$d accounts… + "Enter the profile of a friend or community leader. You can use their npub, NIP-05 address, or a Namecoin name like alice@example.bit or id/alice for blockchain-verified identities." + Select All + %1$d%% uptime + Namecoin Settings diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index bf1f8e109..ccf648e46 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -30,10 +30,10 @@ import com.google.mlkit.nl.translate.TranslateLanguage import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.TranslatorOptions -import com.linkedin.urls.detection.UrlDetector -import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector import kotlinx.coroutines.CancellationException +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.regex.Pattern @@ -45,7 +45,7 @@ data class ResultOrError( ) object LanguageTranslatorService { - var executorService = Executors.newCachedThreadPool() + var executorService: ExecutorService = Executors.newCachedThreadPool() private val options = LanguageIdentificationOptions @@ -54,8 +54,8 @@ object LanguageTranslatorService { .setConfidenceThreshold(0.6f) .build() private val languageIdentification = LanguageIdentification.getClient(options) - val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) - val tagRegex = + val lnRegex: Pattern = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) + val tagRegex: Pattern = Pattern.compile( "(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE, @@ -185,8 +185,7 @@ object LanguageTranslatorService { } private fun urlDictionary(text: String): Map { - val parser = UrlDetector(text, UrlDetectorOptions.Default) - val urlsInText = parser.detect() + val urlsInText = UrlDetector(text).detect() var counter = 0 diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index b80c289f5..7ba915c3e 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -55,7 +55,7 @@ class PushNotificationReceiverService : FirebaseMessagingService() { } } - private suspend fun parseMessage(params: Map): GiftWrapEvent? { + private fun parseMessage(params: Map): GiftWrapEvent? { params["encryptedEvent"]?.let { eventStr -> (Event.fromJson(eventStr) as? GiftWrapEvent)?.let { return it diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index 66ce6e751..6a209241c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -52,7 +52,7 @@ class NewMessageTaggerKeyParseTest { com.vitorpamplona.amethyst.model .Note(hex) - override suspend fun getOrCreateAddressableNote(address: Address) = + override fun getOrCreateAddressableNote(address: Address) = com.vitorpamplona.amethyst.model .AddressableNote(address) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt new file mode 100644 index 000000000..4eb495af6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt @@ -0,0 +1,181 @@ +/* + * 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.service.namecoin + +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NamecoinSettingsTest { + // ── Server string parsing ────────────────────────────────────────── + + @Test + fun `parses host colon port as TLS`() { + val s = NamecoinSettings.parseServerString("example.com:50006") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + assertTrue(s.useSsl) + } + + @Test + fun `parses host colon port colon tcp as plaintext`() { + val s = NamecoinSettings.parseServerString("example.com:50001:tcp") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + } + + @Test + fun `parses onion address`() { + val s = NamecoinSettings.parseServerString("abc123def.onion:50001:tcp") + assertNotNull(s) + assertEquals("abc123def.onion", s!!.host) + assertEquals(50001, s.port) + assertFalse(s.useSsl) + assertTrue(s.trustAllCerts) + } + + @Test + fun `trims whitespace`() { + val s = NamecoinSettings.parseServerString(" example.com : 50006 ") + assertNotNull(s) + assertEquals("example.com", s!!.host) + assertEquals(50006, s.port) + } + + @Test + fun `rejects empty host`() { + assertNull(NamecoinSettings.parseServerString(":50006")) + } + + @Test + fun `rejects invalid port`() { + assertNull(NamecoinSettings.parseServerString("example.com:abc")) + assertNull(NamecoinSettings.parseServerString("example.com:0")) + assertNull(NamecoinSettings.parseServerString("example.com:99999")) + } + + @Test + fun `rejects no port`() { + assertNull(NamecoinSettings.parseServerString("example.com")) + } + + // ── Format round-trip ────────────────────────────────────────────── + + @Test + fun `formats TLS server without suffix`() { + val server = ElectrumxServer("example.com", 50006, true) + assertEquals("example.com:50006", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `formats TCP server with tcp suffix`() { + val server = ElectrumxServer("example.com", 50001, false) + assertEquals("example.com:50001:tcp", NamecoinSettings.formatServerString(server)) + } + + @Test + fun `round-trips server string through parse and format`() { + val original = "myserver.onion:50001:tcp" + val parsed = NamecoinSettings.parseServerString(original)!! + val formatted = NamecoinSettings.formatServerString(parsed) + assertEquals(original, formatted) + } + + // ── toElectrumxServers ───────────────────────────────────────────── + + @Test + fun `returns null when no custom servers`() { + val settings = NamecoinSettings(customServers = emptyList()) + assertNull(settings.toElectrumxServers()) + } + + @Test + fun `returns parsed list for valid custom servers`() { + val settings = + NamecoinSettings( + customServers = + listOf( + "server1.com:50006", + "server2.onion:50001:tcp", + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(2, servers!!.size) + assertEquals("server1.com", servers[0].host) + assertTrue(servers[0].useSsl) + assertEquals("server2.onion", servers[1].host) + assertFalse(servers[1].useSsl) + assertTrue(servers[1].trustAllCerts) + } + + @Test + fun `skips invalid entries in custom server list`() { + val settings = + NamecoinSettings( + customServers = + listOf( + "valid.com:50006", + "invalid", // no port + "also-invalid:abc", // non-numeric port + ), + ) + val servers = settings.toElectrumxServers() + assertNotNull(servers) + assertEquals(1, servers!!.size) + assertEquals("valid.com", servers[0].host) + } + + @Test + fun `returns null when all custom servers are invalid`() { + val settings = NamecoinSettings(customServers = listOf("bad", "also-bad")) + assertNull(settings.toElectrumxServers()) + } + + // ── hasCustomServers flag ────────────────────────────────────────── + + @Test + fun `hasCustomServers is false when empty`() { + assertFalse(NamecoinSettings().hasCustomServers) + } + + @Test + fun `hasCustomServers is true when populated`() { + assertTrue(NamecoinSettings(customServers = listOf("x:1")).hasCustomServers) + } + + // ── Default settings ─────────────────────────────────────────────── + + @Test + fun `default settings are enabled with no custom servers`() { + val d = NamecoinSettings.DEFAULT + assertTrue(d.enabled) + assertTrue(d.customServers.isEmpty()) + assertFalse(d.hasCustomServers) + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt index d51e8557c..f7c1735fd 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/RichTextParserBenchmark.kt @@ -23,14 +23,14 @@ package com.vitorpamplona.amethyst.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.linkedin.urls.detection.UrlDetector -import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector import junit.framework.TestCase.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -117,21 +117,21 @@ class RichTextParserBenchmark { @Test fun computeTestCase2UrlDetector() { benchmarkRule.measureRepeated { - UrlDetector(testCase2, UrlDetectorOptions.Default).detect() + UrlDetector(testCase2).detect() } } @Test fun computeTestCase2UrlDetectorWJapanese() { benchmarkRule.measureRepeated { - UrlDetector(testCaseJapanese, UrlDetectorOptions.Default).detect() + UrlDetector(testCaseJapanese).detect() } } @Test fun computeTestCase2ParseUrls() { benchmarkRule.measureRepeated { - RichTextParser().parseValidUrls(testCase2) + UrlParser().parseValidUrls(testCase2) } } diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 40a31eb70..1d6ce95e1 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -86,8 +86,6 @@ kotlin { create("jvmAndroid") { dependsOn(commonMain.get()) dependencies { - // URL detection (JVM library, works on both) - implementation(libs.url.detector) } } diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index ef99a2aa0..bccf1e297 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -5,14 +5,14 @@ Sign in to your Nostr account A Nostr client for desktop Login with your Nostr key - Use nsec for full access or npub for read-only + nsec for full access, bunker:// for remote signer, or npub for read-only Login with Key Login Generate New Key Generate New Enter your private key (nsec) or public key (npub) - nsec or npub - nsec1... or npub1... + nsec, bunker://, or npub + nsec1… / bunker://… / npub1… Show key Hide key diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt new file mode 100644 index 000000000..42589c369 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt @@ -0,0 +1,29 @@ +/* + * 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.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote + +data class BunkerLoginResult( + val signer: NostrSignerRemote, + val pubKeyHex: HexKey, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt new file mode 100644 index 000000000..4962f5e3e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt @@ -0,0 +1,51 @@ +/* + * 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.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout + +object BunkerLoginUseCase { + const val RELAY_CONNECT_TIMEOUT_MS = 15_000L + + suspend fun execute( + bunkerUri: String, + ephemeralSigner: NostrSignerInternal, + client: INostrClient, + ): BunkerLoginResult { + val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client) + remoteSigner.openSubscription() + + // Wait for websocket to be ready before sending connect request + withTimeout(RELAY_CONNECT_TIMEOUT_MS) { + client.connectedRelaysFlow().first { connected -> + remoteSigner.relays.any { it in connected } + } + } + + remoteSigner.connect() + val pubkey = remoteSigner.getPublicKey() + return BunkerLoginResult(remoteSigner, pubkey) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt new file mode 100644 index 000000000..674a38219 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt @@ -0,0 +1,218 @@ +/* + * 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.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.SecureRandom +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout + +object NostrConnectLoginUseCase { + const val NOSTRCONNECT_TIMEOUT_MS = 120_000L + + data class NostrConnectUri( + val uri: String, + val ephemeralKeyPair: KeyPair, + val ephemeralSigner: NostrSignerInternal, + val relays: Set, + val secret: String, + ) + + data class ConnectRequestData( + val requestId: String?, + val signerPubkey: HexKey, + val userPubkey: HexKey, + ) + + fun generateUri( + ephemeralKeyPair: KeyPair, + relays: List, + appName: String, + ): NostrConnectUri { + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey() + val secret = generateSecret() + + val relayParams = relays.joinToString("&") { "relay=$it" } + val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=$appName" + val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() + + return NostrConnectUri( + uri = uri, + ephemeralKeyPair = ephemeralKeyPair, + ephemeralSigner = ephemeralSigner, + relays = normalizedRelays, + secret = secret, + ) + } + + suspend fun awaitAndLogin( + uriData: NostrConnectUri, + client: INostrClient, + ): BunkerLoginResult { + val connectData = + waitForConnectRequest( + ephemeralSigner = uriData.ephemeralSigner, + ephemeralPubKey = uriData.ephemeralKeyPair.pubKey.toHexKey(), + relays = uriData.relays, + expectedSecret = uriData.secret, + client = client, + ) + + if (connectData.requestId != null) { + sendAckResponse(uriData.ephemeralSigner, connectData, uriData.relays, client) + } + + val remoteSigner = + NostrSignerRemote( + signer = uriData.ephemeralSigner, + remotePubkey = connectData.signerPubkey, + relays = uriData.relays, + client = client, + ) + remoteSigner.openSubscription() + + val verifiedPubkey = remoteSigner.getPublicKey() + + return BunkerLoginResult(remoteSigner, verifiedPubkey) + } + + private suspend fun waitForConnectRequest( + ephemeralSigner: NostrSignerInternal, + ephemeralPubKey: HexKey, + relays: Set, + expectedSecret: String, + client: INostrClient, + ): ConnectRequestData { + val deferred = CompletableDeferred() + + val subscription = + client.req( + relays = relays.toList(), + filter = + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(ephemeralPubKey)), + since = TimeUtils.now() - 60, + ), + ) { event -> + if (event is NostrConnectEvent && !deferred.isCompleted) { + deferred.complete(event) + } + } + + try { + val event = + withTimeout(NOSTRCONNECT_TIMEOUT_MS) { + deferred.await() + } + + val signerPubkey = event.talkingWith(ephemeralSigner.pubKey) + val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey) + val message = OptimizedJsonMapper.fromJsonTo(decryptedJson) + + return when (message) { + is BunkerRequest -> { + if (message.method != BunkerRequestConnect.METHOD_NAME) { + throw Exception("Expected 'connect' method, got '${message.method}'") + } + + val userPubkey = + message.params.getOrNull(0) + ?: throw Exception("Missing user pubkey in connect request") + val receivedSecret = message.params.getOrNull(1) + + if (receivedSecret != expectedSecret) { + throw Exception("Secret mismatch in connect request") + } + + ConnectRequestData( + requestId = message.id, + signerPubkey = signerPubkey, + userPubkey = userPubkey, + ) + } + + is BunkerResponse -> { + if (message.error != null) { + throw Exception("Signer rejected connection: ${message.error}") + } + + val userPubkey = + message.result + ?.takeIf { it.length == 64 && Hex.isHex(it) } + ?: signerPubkey + + ConnectRequestData( + requestId = null, + signerPubkey = signerPubkey, + userPubkey = userPubkey, + ) + } + + else -> { + throw Exception("Unexpected NIP-46 message format") + } + } + } finally { + subscription.close() + } + } + + private suspend fun sendAckResponse( + ephemeralSigner: NostrSignerInternal, + connectData: ConnectRequestData, + relays: Set, + client: INostrClient, + ) { + val ackResponse = BunkerResponseAck(id = connectData.requestId!!) + val ackEvent = + NostrConnectEvent.create( + message = ackResponse, + remoteKey = connectData.signerPubkey, + signer = ephemeralSigner, + ) + client.send(ackEvent, relays) + } + + private fun generateSecret(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt new file mode 100644 index 000000000..321df54a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt @@ -0,0 +1,29 @@ +/* + * 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.commons.domain.nip46 + +sealed class SignerConnectionState { + data object NotRemote : SignerConnectionState() + + data object Connected : SignerConnectionState() + + data object Disconnected : SignerConnectionState() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 9e95521a2..a66d32bab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -890,7 +890,7 @@ open class Note( return true } - if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true + if (author?.metadataOrNull()?.anyPropertyContains(accountChoices.hiddenWordsCase) == true) return true } return false diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index ed4fbc59f..10f727abc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex interface UserDependencies @@ -141,8 +140,6 @@ class User( fun relayStateOrNull(): UserRelaysCache? = relays fun relayState(): UserRelaysCache = relays ?: UserRelaysCache().also { relays = it } - - fun containsAny(hiddenWordsCase: List) = metadataOrNull()?.containsAny(hiddenWordsCase) == true } fun Set.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt index 99b11e05d..ffa1e2537 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims import com.vitorpamplona.quartz.utils.DualCase -import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -60,48 +59,16 @@ class UserMetadataCache { fun shouldUpdateWith(event: MetadataEvent) = event.createdAt > (flow.value?.createdAt ?: 0) - fun anyNameStartsWith(username: String): Boolean = flow.value?.info?.anyNameStartsWith(username) ?: false + fun anyNameOrAddressContains(terms: List): Boolean = flow.value?.info?.anyNameOrAddressContains(terms) ?: false - fun containsAny(hiddenWordsCase: List): Boolean { + fun anyNameStartsWith(terms: List): Boolean = flow.value?.info?.anyNameStartsWith(terms) ?: false + + fun anyAddressStartsWith(terms: List): Boolean = flow.value?.info?.anyAddressStartsWith(terms) ?: false + + fun anyPropertyContains(hiddenWordsCase: List): Boolean { if (hiddenWordsCase.isEmpty()) return false - flow.value?.let { userInfo -> - val info = userInfo.info - - if (info.name?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info.displayName?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info.picture?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info.banner?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info.about?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info.lud06?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info.lud16?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info.nip05?.containsAny(hiddenWordsCase) == true) { - return true - } - } - - return false + return flow.value?.info?.anyPropertyContains(hiddenWordsCase) ?: false } fun bestName(): String? = flow.value?.info?.bestName() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt index ebe907216..0bef42b0b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client +import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow @@ -49,7 +49,7 @@ sealed interface Nip05State { fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted) - suspend fun checkAndUpdate(nip05Client: Nip05Client) { + suspend fun checkAndUpdate(nip05Client: INip05Client) { if (verificationState.value.isExpired()) { markAsVerifying() try { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt index 0132220ac..f335889e4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt @@ -44,10 +44,11 @@ class ExpandableTextCutOffCalculator { if (min > TOO_FAR_SEARCH_THE_OTHER_WAY) { // if it is still too big, finds the first space or new line BEFORE the cut off. val newString = content.take(SHORT_TEXT_LENGTH) + val firstSpaceBeforeCut = - newString.lastIndexOf(' ').let { if (it < 0) content.length else it } + newString.lastIndexOf(' ').let { if (it < 0) newString.length else it } val firstNewLineBeforeCut = - newString.lastIndexOf('\n').let { if (it < 0) content.length else it } + newString.lastIndexOf('\n').let { if (it < 0) newString.length else it } maxOf(firstSpaceBeforeCut, firstNewLineBeforeCut) } else { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 103da1204..7f39960b6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import com.linkedin.urls.detection.UrlDetector -import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata @@ -39,7 +37,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap -import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toPersistentList import java.net.MalformedURLException import java.net.URISyntaxException @@ -101,27 +98,43 @@ class RichTextParser { } } - fun parseValidUrls(content: String): LinkedHashSet { - val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + fun fixMissingSpaces( + input: String, + urlList: Set, + ): String { + if (urlList.isEmpty()) return input - return urls.mapNotNullTo(LinkedHashSet(urls.size)) { - if (it.originalUrl.contains("@")) { - if (Patterns.EMAIL_ADDRESS.matches(it.originalUrl)) { - null - } else { - it.originalUrl - } - } else if (isNumber(it.originalUrl)) { - null // avoids urls that look like 123.22 - } else if (it.originalUrl.contains("。")) { - null // avoids Japanese characters as fake urls - } else { - if (HTTPRegex.matches(it.originalUrl)) { - it.originalUrl - } else { - null - } + // Escape and join words: (word1|word2) + val wordsPattern = urlList.sortedByDescending { it.length }.joinToString("|") { Regex.escape(it) } + + // Regex breakdown: + // ([^ ])? -> Group 1: Optional character that is NOT a space or new line (Prefix) + // ($wordsPattern) -> Group 2: One of your target words + // ([^ ])? -> Group 3: Optional character that is NOT a space or new line (Suffix) + val regex = Regex("([^ \n])?($wordsPattern)([^ \n])?") + + return regex.replace(input) { match -> + val prefix = match.groups[1]?.value ?: "" + val word = match.groups[2]?.value ?: "" + val suffix = match.groups[3]?.value ?: "" + + val result = StringBuilder() + + // Add prefix + space if the prefix exists + if (prefix.isNotEmpty()) { + result.append(prefix) + result.append(" ") } + + result.append(word) + + // Add space + suffix if the suffix exists + if (suffix.isNotEmpty()) { + result.append(" ") + result.append(suffix) + } + + result.toString() } } @@ -131,30 +144,41 @@ class RichTextParser { callbackUri: String?, ): RichTextViewerState { val imetas = tags.lists.imetasByUrl() - val urlSet = parseValidUrls(content) + val urlSet = UrlParser().parseValidUrls(content) - val imagesForPager = - urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, imetas, content, callbackUri) }.associateBy { it.url } + val mediaContents = + urlSet.withScheme.mapNotNull { fullUrl -> + createMediaContent(fullUrl, imetas, content, callbackUri) + } + + urlSet.withoutScheme.mapNotNull { fullUrl -> + createMediaContent(fullUrl, imetas, content, callbackUri) + } - val imageUrls = imagesForPager.filterValues { it is MediaUrlImage }.keys - val videoUrls = imagesForPager.filterValues { it is MediaUrlVideo }.keys + val mediaForPager = mediaContents.associateBy { it.url } + + val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys + val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys val emojiMap = CustomEmoji.createEmojiMap(tags.lists) - val segments = findTextSegments(content, imageUrls, videoUrls, urlSet, emojiMap, tags) + val allUrls = urlSet.withScheme + urlSet.withoutScheme + urlSet.emails + urlSet.bech32s + urlSet.relayUrls - val base64Images = segments.map { it.words.filterIsInstance() }.flatten() + val newContent = fixMissingSpaces(content, allUrls) - val imagesForPagerWithBase64 = - imagesForPager + + val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags) + + val base64Images = segments.flatMap { it.words.filterIsInstance() } + + val mediaForPagerWithBase64 = + mediaForPager + base64Images .mapNotNull { createMediaContent(it.segmentText, emptyMap(), content, callbackUri) } .associateBy { it.url } return RichTextViewerState( - urlSet.toImmutableSet(), - imagesForPagerWithBase64.toImmutableMap(), - imagesForPagerWithBase64.values.toImmutableList(), + urlSet, + mediaForPagerWithBase64.toImmutableMap(), + mediaForPagerWithBase64.values.toImmutableList(), emojiMap.toImmutableMap(), segments, tags, @@ -165,7 +189,7 @@ class RichTextParser { content: String, images: Set, videos: Set, - urls: Set, + urls: Urls, emojis: Map, tags: ImmutableListOfLists, ): ImmutableList { @@ -175,18 +199,14 @@ class RichTextParser { lines.forEach { paragraph -> val isRTL = isArabic(paragraph) - val wordList = paragraph.trimEnd().split(wordBoundaryRegex).filter { it.isNotEmpty() } + val wordList = paragraph.trimEnd().split(' ') - if (wordList.isEmpty()) { - paragraphSegments.add(ParagraphState(persistentListOf(RegularTextSegment("")), isRTL)) - } else { - val segments = ArrayList(wordList.size) - wordList.forEach { word -> - segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) - } - - paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) + val segments = ArrayList(wordList.size) + wordList.forEach { word -> + segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) } + + paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) } val segmentsWithGalleries = GalleryParser().processParagraphs(paragraphSegments) @@ -204,8 +224,6 @@ class RichTextParser { }.toImmutableList() } - private fun isNumber(word: String) = numberPattern.matches(word) - private fun isPhoneNumberChar(c: Char): Boolean = when (c) { in '0'..'9' -> true @@ -236,7 +254,7 @@ class RichTextParser { word: String, images: Set, videos: Set, - urls: Set, + urls: Urls, emojis: Map, tags: ImmutableListOfLists, ): Segment { @@ -246,13 +264,33 @@ class RichTextParser { if (Patterns.BASE64_IMAGE.matches(word)) return Base64Segment(word) } - if (images.contains(word)) return ImageSegment(word) + if (images.contains(word)) { + return if (urls.withoutScheme.contains(word)) { + ImageSegment("https://$word") + } else { + ImageSegment(word) + } + } - if (videos.contains(word)) return VideoSegment(word) + if (videos.contains(word)) { + return if (urls.withoutScheme.contains(word)) { + VideoSegment("https://$word") + } else { + VideoSegment(word) + } + } - if (word.startsWith("ws://") || word.startsWith("wss://")) return RelayUrlSegment(word) + if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word) - if (urls.contains(word)) return LinkSegment(word) + if (urls.withScheme.contains(word)) return LinkSegment(word) + + if (urls.emails.contains(word)) return EmailSegment(word) + + if (urls.bech32s.contains(word)) return BechSegment(word) + + if (urls.relayUrls.contains(word)) return RelayUrlSegment(word) + + if (startsWithNIP19Scheme(word)) return BechSegment(word) if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word) @@ -262,32 +300,14 @@ class RichTextParser { if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word) - if (word.startsWith("#")) return parseHash(word, tags) + if (word.startsWith('#')) return parseHash(word, tags) if (EmojiCoder.isCoded(word)) return SecretEmoji(word) - if (word.contains("@")) { - if (Patterns.EMAIL_ADDRESS.matches(word)) return EmailSegment(word) - } - - if (startsWithNIP19Scheme(word)) return BechSegment(word) - if (isPotentialPhoneNumber(word) && !isDate(word)) { if (Patterns.PHONE.matches(word)) return PhoneSegment(word) } - val indexOfPeriod = word.indexOf(".") - if (indexOfPeriod > 0 && indexOfPeriod < word.length - 1) { // periods cannot be the last one - val schemelessMatcher = noProtocolUrlValidator.find(word) - if (schemelessMatcher != null) { - val url = schemelessMatcher.groups[1]?.value // url - val additionalChars = schemelessMatcher.groups[4]?.value?.ifEmpty { null } // additional chars - if (additionalUrlSchema.find(word) != null && url != null) { - return SchemelessUrlSegment(word, url, additionalChars) - } - } - } - return RegularTextSegment(word) } @@ -343,7 +363,7 @@ class RichTextParser { val noProtocolUrlValidator = Regex( - "(([a-zA-Z0-9_-]+\\.)*[a-zA-Z][a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)", + "(([a-zA-Z0-9_-]+@)?([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)", ) // Splits at spaces AND at ASCII/multibyte character boundaries diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 292b07ddc..b56a47b19 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -24,11 +24,10 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.ImmutableSet @Immutable class RichTextViewerState( - val urlSet: ImmutableSet, + val urlSet: Urls, val imagesForPager: ImmutableMap, val imageList: ImmutableList, val customEmoji: ImmutableMap, @@ -140,14 +139,12 @@ class HashTagSegment( ) : Segment(segment) @Immutable -class SchemelessUrlSegment( +class RelayUrlSegment( segment: String, - val url: String, - val extras: String?, ) : Segment(segment) @Immutable -class RelayUrlSegment( +class SchemelessUrlSegment( segment: String, ) : Segment(segment) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt new file mode 100644 index 000000000..df78d3457 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt @@ -0,0 +1,107 @@ +/* + * 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.commons.richtext + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.startsWithAny +import com.vitorpamplona.quartz.utils.urldetector.Url +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector + +@Stable +class Urls( + val withScheme: Set = emptySet(), + val withoutScheme: Set = emptySet(), + val emails: Set = emptySet(), + val bech32s: Set = emptySet(), + val relayUrls: Set = emptySet(), +) + +val websocketScheme = listOf(DualCase("ws")) +val nostrScheme = listOf(DualCase("nostr")) +val blossomScheme = listOf(DualCase("blossom")) + +class UrlParser { + fun Char.isAsciiLetter(): Boolean = (this in 'a'..'z' || this in 'A'..'Z') + + fun Url.isValidTopLevelDomain(): Boolean { + /* + According to the TLD Applicant Guidebook published June 2012, ICANN does not allow numbers in TLDs. + */ + val startOfTopDomain = host.lastIndexOf('.') + 1 + return if (startOfTopDomain < host.length) { + val topLevelDomain = host.substring(startOfTopDomain) + topLevelDomain.isNotEmpty() && topLevelDomain[0].isAsciiLetter() + } else { + false + } + } + + fun Url.wroteWithSchema(): Boolean = urlMarker.hasScheme() + + fun Url.isEmail(): Boolean = + urlMarker.hasUsernamePassword() && + !urlMarker.hasQuery() && + !urlMarker.hasFragment() && + originalUrl.contains('@') && + path == "/" + + fun parseValidUrls(content: String): Urls { + val urls = UrlDetector(content).detect() + + val completeUrls = mutableSetOf() + val urlsWithoutScheme = mutableSetOf() + val emails = mutableSetOf() + val bech32 = mutableSetOf() + val relays = mutableSetOf() + + urls.forEach { + if (it.isValidTopLevelDomain()) { + if (it.wroteWithSchema()) { + if (it.originalUrl.startsWithAny(nostrScheme)) { + bech32.add(it.originalUrl) + } else if (it.originalUrl.startsWithAny(websocketScheme)) { + relays.add(it.originalUrl) + } else { + completeUrls.add(it.originalUrl) + } + } else { + // emails are understood as urls from the detector. + if (it.isEmail()) { + Patterns.EMAIL_ADDRESS.findAll(it.originalUrl).forEach { + emails.add(it.value) + } + } else { + urlsWithoutScheme.add(it.originalUrl) + } + } + } + } + + return Urls( + withScheme = completeUrls, + withoutScheme = urlsWithoutScheme, + emails = emails, + bech32s = bech32, + relayUrls = relays, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt new file mode 100644 index 000000000..7c295ef91 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt @@ -0,0 +1,117 @@ +/* + * 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.commons.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.quartz.utils.TimeUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BunkerHeartbeatIndicator( + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, + modifier: Modifier = Modifier, +) { + if (signerConnectionState is SignerConnectionState.NotRemote) return + + val tooltipState = rememberTooltipState() + val tooltipText = + when (signerConnectionState) { + is SignerConnectionState.Connected -> { + if (lastPingTimeSec != null) { + val agoSeconds = TimeUtils.now() - lastPingTimeSec + "Bunker connected \u2014 last pinged ${agoSeconds}s ago" + } else { + "Bunker connected" + } + } + + is SignerConnectionState.Disconnected -> { + "Bunker disconnected" + } + + else -> { + "" + } + } + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(tooltipText) } }, + state = tooltipState, + modifier = modifier, + ) { + when (signerConnectionState) { + is SignerConnectionState.Connected -> { + val infiniteTransition = rememberInfiniteTransition(label = "heartbeat") + val scale by infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1.15f, + animationSpec = + infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse, + ), + label = "heartbeatScale", + ) + Icon( + Icons.Default.Favorite, + contentDescription = "Bunker connected", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(20.dp).scale(scale), + ) + } + + is SignerConnectionState.Disconnected -> { + Icon( + Icons.Default.FavoriteBorder, + contentDescription = "Bunker disconnected", + tint = Color(0xFFF44336), + modifier = Modifier.size(20.dp), + ) + } + + else -> {} + } + } +} diff --git a/commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt similarity index 69% rename from commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt index 5a927a663..3c5733176 100644 --- a/commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt @@ -20,26 +20,23 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals -@RunWith(AndroidJUnit4::class) class ExpandableTextCutOffCalculatorTest { val shortPost: String = "Goldman Sachs Predicts Modest Gains for S&P 500 in 2024 ( #409962b2 , v0.09)" - val longPost: String = - "Goldman Sachs Predicts Modest Gains for S&P 500 in 2024 ( #409962b2 , v0.09)\n" + - "\n" + - "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIiA/Pgo8c3ZnIHdpZHRoPSIzMjAiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCAzMjAgNjUiIGZpbGw9ImJsYWNrIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aAogICAgZmlsbC1ydWxlPSJldmVub2RkIgogICAgY2xpcC1ydWxlPSJldmVub2RkIgogICAgZD0iTTguNTk4MjggNDIuMTU1OEM3LjgxMjgxIDQ5LjA1OTkgNS45ODMyNCA0OC44Njg4IDMuNzYzMDYgNDkuNDMzOUMxLjc1MjU1IDQ5Ljk0MzggMi41MTk5MyA1MS4xMjcyIDMuODI2OTIgNTAuOTQwMkM0LjMxMjI1IDUwLjg3MDcgNy45MDU0MSA1MC41MDE4IDEwLjM2MTkgNTAuNTAxOEMxMy44MDI4IDUwLjUwMTggMTYuOTI1NiA1MC44MTM1IDE4LjYwOTMgNTAuODEzNUMxOS4zMTE4IDUwLjgxMzUgMjAuMjMyNCA0OS44NzU0IDE4LjUzMTYgNDkuNDMzOUMxNi41NjQ3IDQ4LjkyNiAxMS43MzkxIDQ5LjQzMzkgMTEuMjc4MyA0MS4xNTEzQzEwLjk0MyAzNS4wOTE0IDExLjUwMTggMTQuMzcxMSAxMS41MDE4IDE0LjM3MTFDMTEuNTAxOCAxMi45MjQxIDExLjcxMzYgMTIuNzA1NCAxMS45OTM1IDEyLjcwNTRDMTIuNDgyIDEyLjcwNTQgMTIuOTk1IDEzLjUyMTkgMTMuNDY5NyAxNC4wODE5TDQzLjQ5NzUgNTAuNzQ5MUM0Ni42NjcgNTQuMzA1MyA0Ni44NjM5IDUzLjIzNzQgNDYuOCA0OS42NjA4QzQ2LjU5MTQgMzguMTE4MyA0Ni45NjI5IDYuMjYxMjQgNDYuOTYyOSA2LjI2MTI0QzQ3LjA1NDQgLTAuMDA4MTc1MTYgNTIuOTQyMyAyLjUzNzQgNTIuOTQyMyAwLjY1NDAyM0M1Mi45NDIzIDAuMjE4Njg5IDUyLjY2MjMgMCA1Mi4yNDE5IDBDNTAuNTU2IDAgNDUuNzA3IDAuNTA0ODI0IDQzLjMyMDggMC41MDQ4MjRDNDEuMDcxOSAwLjUwNDgyNCAzOS43NDE1IDAuMTc4ODM0IDM3LjYzNTIgMC4xNzg4MzRDMzcuMjE2OSAwLjE3ODgzNCAzNi44NTYxIDAuMzk3NTIzIDM2Ljg2MzUgMC45MDU0MTJDMzYuODk1NSAyLjkzNzk5IDQ0LjQ0NjggLTAuNDEzODc0IDQ0LjE4NSA5Ljk0MDEyTDQ0LjI1OTUgMzkuOTM2M0M0NC4yNTk1IDQwLjUxNDcgNDQuMjE5MSA0MS42NTUxIDQzLjIwNjkgMzkuOTM2M0wxMS43Njc4IDEuOTA3OTFDMTEuMzQ1MyAxLjQwMTA0IDEwLjcxNTIgMC4yNDExNzEgOS45NDE0NiAwLjI0MTE3MUM4Ljg4ODg0IDAuMjQxMTcxIDUuNDA5NTcgMC41MDQ4MjQgNC4zNTY5NSAwLjUwNDgyNEMzLjIzNDA5IDAuNTA0ODI0IDIuMTgwNDEgMC4yMTU2MjMgMS4wNTc1NSAwLjIxNTYyM0MwLjcwNzM4OSAwLjIxNTYyMyAtMC4wNjEwNTI5IDAuNTA3ODg5IDAuMDAzODcwODYgMC45MzkxMzZDMC4zOTg3MzUgMy40NzY1NCA4LjYzMTI4IDAuNDY3MDEyIDguNzY0MzIgMTIuMzI2M0M4Ljc2NDMyIDEyLjMyNjMgOC45MzU2NyAzOS4xNzcgOC41OTgyOCA0Mi4xNTU4WiIKICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyMy4yMjQgNS43NDIxOSkiCiAgLz4KPHBhdGgKICAgIGZpbGwtcnVsZT0iZXZlbm9kZCIKICAgIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgIGQ9Ik01LjY1NTE4IDAuMDU3NjM2MkM0LjcwOSAwLjMzMzU1MiAzLjIwNDA0IDEuNTI1MSAyLjU4NTY3IDEuOTM4OTdDMS44NzM2NCAyLjQxODI1IDEuODg4NTQgMy4yMTAyMyAyLjI1MzYgMy44ODU3MUM0LjAyMjUxIDcuMTQ4NjcgNS43NzExOSAxMi41MzQxIDEuNDA2NCAxOS4wMUMwLjY4Nzk4MSAyMC4wNzU4IC0wLjQyMzE3MiAyMC4zODk2IDAuMTY2NDYzIDIwLjgzQzAuNzg4MDI4IDIxLjI5NiAyLjc5MTA4IDE5Ljk5MSAzLjYyNjU4IDE5LjE5N0M4LjMzNTE1IDE0Ljc0MDQgMTAuMDMzOCA3LjE0ODY3IDcuMzUzODQgMS42MjYyN0M2LjgwODkxIDAuNTAzMTg5IDYuNTg2NDYgLTAuMjE0MTkyIDUuNjU1MTggMC4wNTc2MzYyWiIKICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3NS4wNzQpIgogIC8+CjxwYXRoCiAgICBmaWxsLXJ" + + val image: String = + "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIiA/Pgo8c3ZnIHdpZHRoPSIzMjAiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCAzMjAgNjUiIGZpbGw9ImJsYWNrIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aAogICAgZmlsbC1ydWxlPSJldmVub2RkIgogICAgY2xpcC1ydWxlPSJldmVub2RkIgogICAgZD0iTTguNTk4MjggNDIuMTU1OEM3LjgxMjgxIDQ5LjA1OTkgNS45ODMyNCA0OC44Njg4IDMuNzYzMDYgNDkuNDMzOUMxLjc1MjU1IDQ5Ljk0MzggMi41MTk5MyA1MS4xMjcyIDMuODI2OTIgNTAuOTQwMkM0LjMxMjI1IDUwLjg3MDcgNy45MDU0MSA1MC41MDE4IDEwLjM2MTkgNTAuNTAxOEMxMy44MDI4IDUwLjUwMTggMTYuOTI1NiA1MC44MTM1IDE4LjYwOTMgNTAuODEzNUMxOS4zMTE4IDUwLjgxMzUgMjAuMjMyNCA0OS44NzU0IDE4LjUzMTYgNDkuNDMzOUMxNi41NjQ3IDQ4LjkyNiAxMS43MzkxIDQ5LjQzMzkgMTEuMjc4MyA0MS4xNTEzQzEwLjk0MyAzNS4wOTE0IDExLjUwMTggMTQuMzcxMSAxMS41MDE4IDE0LjM3MTFDMTEuNTAxOCAxMi45MjQxIDExLjcxMzYgMTIuNzA1NCAxMS45OTM1IDEyLjcwNTRDMTIuNDgyIDEyLjcwNTQgMTIuOTk1IDEzLjUyMTkgMTMuNDY5NyAxNC4wODE5TDQzLjQ5NzUgNTAuNzQ5MUM0Ni42NjcgNTQuMzA1MyA0Ni44NjM5IDUzLjIzNzQgNDYuOCA0OS42NjA4QzQ2LjU5MTQgMzguMTE4MyA0Ni45NjI5IDYuMjYxMjQgNDYuOTYyOSA2LjI2MTI0QzQ3LjA1NDQgLTAuMDA4MTc1MTYgNTIuOTQyMyAyLjUzNzQgNTIuOTQyMyAwLjY1NDAyM0M1Mi45NDIzIDAuMjE4Njg5IDUyLjY2MjMgMCA1Mi4yNDE5IDBDNTAuNTU2IDAgNDUuNzA3IDAuNTA0ODI0IDQzLjMyMDggMC41MDQ4MjRDNDEuMDcxOSAwLjUwNDgyNCAzOS43NDE1IDAuMTc4ODM0IDM3LjYzNTIgMC4xNzg4MzRDMzcuMjE2OSAwLjE3ODgzNCAzNi44NTYxIDAuMzk3NTIzIDM2Ljg2MzUgMC45MDU0MTJDMzYuODk1NSAyLjkzNzk5IDQ0LjQ0NjggLTAuNDEzODc0IDQ0LjE4NSA5Ljk0MDEyTDQ0LjI1OTUgMzkuOTM2M0M0NC4yNTk1IDQwLjUxNDcgNDQuMjE5MSA0MS42NTUxIDQzLjIwNjkgMzkuOTM2M0wxMS43Njc4IDEuOTA3OTFDMTEuMzQ1MyAxLjQwMTA0IDEwLjcxNTIgMC4yNDExNzEgOS45NDE0NiAwLjI0MTE3MUM4Ljg4ODg0IDAuMjQxMTcxIDUuNDA5NTcgMC41MDQ4MjQgNC4zNTY5NSAwLjUwNDgyNEMzLjIzNDA5IDAuNTA0ODI0IDIuMTgwNDEgMC4yMTU2MjMgMS4wNTc1NSAwLjIxNTYyM0MwLjcwNzM4OSAwLjIxNTYyMyAtMC4wNjEwNTI5IDAuNTA3ODg5IDAuMDAzODcwODYgMC45MzkxMzZDMC4zOTg3MzUgMy40NzY1NCA4LjYzMTI4IDAuNDY3MDEyIDguNzY0MzIgMTIuMzI2M0M4Ljc2NDMyIDEyLjMyNjMgOC45MzU2NyAzOS4xNzcgOC41OTgyOCA0Mi4xNTU4WiIKICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyMy4yMjQgNS43NDIxOSkiCiAgLz4KPHBhdGgKICAgIGZpbGwtcnVsZT0iZXZlbm9kZCIKICAgIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgIGQ9Ik01LjY1NTE4IDAuMDU3NjM2MkM0LjcwOSAwLjMzMzU1MiAzLjIwNDA0IDEuNTI1MSAyLjU4NTY3IDEuOTM4OTdDMS44NzM2NCAyLjQxODI1IDEuODg4NTQgMy4yMTAyMyAyLjI1MzYgMy44ODU3MUM0LjAyMjUxIDcuMTQ4NjcgNS43NzExOSAxMi41MzQxIDEuNDA2NCAxOS4wMUMwLjY4Nzk4MSAyMC4wNzU4IC0wLjQyMzE3MiAyMC4zODk2IDAuMTY2NDYzIDIwLjgzQzAuNzg4MDI4IDIxLjI5NiAyLjc5MTA4IDE5Ljk5MSAzLjYyNjU4IDE5LjE5N0M4LjMzNTE1IDE0Ljc0MDQgMTAuMDMzOCA3LjE0ODY3IDcuMzUzODQgMS42MjYyN0M2LjgwODkxIDAuNTAzMTg5IDYuNTg2NDYgLTAuMjE0MTkyIDUuNjU1MTggMC4wNTc2MzYyWiIKICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3NS4wNzQpIgogIC8+CjxwYXRoCiAgICBmaWxsLXJ" + "1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMS4zODg4NiA0OC4zMTk3QzEuNjc5NDIgNDkuNjE0NSA3LjgwMzU0IDUyLjE0NzggMTMuMjI2MyA1Mi4xNDc4QzI5Ljg2NyA1Mi4xNDc4IDMzLjgwMjggNDMuNDM1IDMzLjgwMjggMzcuNDAwNkMzMy44MDI4IDE4LjMyMzYgNS41Njc0IDI1LjMwNTMgNS41Njc0IDExLjc5NTZDNS41Njc0IDUuMTg4OTUgMTAuODg4IDIuMzg0ODMgMTYuNjc4OSAyLjM4NDgzQzIzLjEzMyAyLjM4NDgzIDI4LjMzOTcgNi4zNzMzNSAzMC4yNDM3IDExLjI1NzFDMzAuNDg5NiAxMS44OTI3IDMwLjgwNjggMTMuMzI5NSAzMS42MTI1IDEzLjMyOTVDMzIuMzQ3OSAxMy4zMjk1IDMyLjE3NjUgMTIuODExNCAzMi4xMDYzIDEyLjIzNkwzMS4wNTU4IDIuMTk3ODJDMzAuOTgzNCAxLjQwNTg0IDMwLjYxOTQgMS4xOTEyNCAzMC4xNzY3IDEuMTkxMjRDMjkuNDQ0NCAxLjE5MTI0IDI4Ljk5ODUgMi40NzE2OSAyNy42Mjg3IDIuMjIwM0MyNi42MTk3IDIuMDM2MzYgMjQuODQ5NyAwLjA2NDA3MyAxNy4yNjk2IDAuMDAwNzE0NTNDOC44MzcgLTAuMDY4Nzc1NCAxLjY1MDY5IDQuOTMyNDUgMS42NTA2OSAxNC4wNTcxQzEuNjUwNjkgMzIuMTI5NiAyOS4wOTc1IDI1LjIyNTYgMjkuMjMxNiAzOC41MzA4QzI5LjI5OTcgNDYuMDczNSAyNC4yOTc0IDQ5Ljk1MDcgMTcuMzMwMyA0OS45NTA3QzcuMjE3MSA0OS45NTA3IDIuNzU1NDYgNDIuNDIyMyAxLjg3ODQ1IDM5LjExODRDMS41MTAyIDM3LjYxMDEgMS4zNTI2OCAzNi43OTc3IDAuNTY3MjA2IDM2LjkyMjNDLTAuMTU0NDA1IDM3LjAzODggLTAuMDg0MTU5NyAzNy44NDYxIDAuMjEwNjU4IDM5LjI4M0MwLjIxMDY1OCAzOS4yODMgMS4xNTc5MSA0Mi4zODE0IDEuMzg4ODYgNDguMzE5N1oiCiAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyODUuNDk1IDQuNDUxMTcpIgogIC8+CjxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMjYuMjA1OCA1My4yMTI5QzQyLjg5OTcgNTMuMjEyOSA1NC41MDUxIDQyLjU1NTQgNTQuNTA1MSAyNi4wMTg5QzU0LjUwNTEgMTAuODAyNiA0Mi43NTE3IDAgMjcuNDc5OCAwQzEyLjEzMDEgMCAwIDExLjA5NjkgMCAyNi4zODY3QzAgNDEuODkzMiA5Ljk1NTY4IDUzLjIxMjkgMjYuMjA1OCA1My4yMTI5Wk0yNS42NTYgMi45MDgyQzQwLjI1NDMgMi45MDgyIDQ5LjA0NTYgMTUuNDMzOCA0OS4wNDU2IDI4LjgwOTVDNDkuMDQ1NiA0Mi40MDkxIDM5Ljg1NzMgNTAuNzI2NCAyOC45MjU2IDUwLjcyNjRDMTQuMDk5NiA1MC43MjY0IDUuNDcyMTcgMzcuODQ5NCA1LjQ3MjE3IDI0LjI1MzlDNS40NzIxNyAxMi42Mzk5IDEzLjA3NjggMi45MDgyIDI1LjY1NiAyLjkwODJaIgogICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTcyLjI1NiA0LjgyODEyKSIKICAvPgo8cGF0aAogICAgZmlsbC1ydWxlPSJldmVub2RkIgogICAgY2xpcC1ydWxlPSJldmVub2RkIgogICAgZD0iTTI3LjA4MDYgMjUuMzkwMkMzMS44MjIyIDIzLjM3MDkgMzYuMjcyMSAxOC44MTkzIDM2LjI3MjEgMTMuMTkwNkMzNi4yNzIxIC0wLjczODAxNiAyMC42MTU5IDAuMDA2OTU2NTEgMTkuNTMxNCAwLjAwNjk1NjUxQzE2LjAwNjQgMC4wMDY5NTY1MSAxMS40MzgzIDAuNjU2ODkxIDcuNTA0NTQgMC42NTY4OTFDNC4wNDk3NSAwLjY1Njg5MSAyLjU3NjczIDAuMjc1NzE5IDEuMzU3MDEgMC4yNzU3MTlDMC41NDI4MDUgMC4yNzU3MTkgMCAwLjcwOTAwOSAwIDEuMTQyM0MwIDMuNTk1OTEgNy44NDE5MyAtMC4wNTAyNzAzIDcuNjQyOTEgNy42NThWNDQuMzkyNkM3LjY0MjkxIDQ5Ljg3NTEgMy42OTIxNCA0OS40MjM1IDIuODQzODcgNTAuMDQ4OUMyLjQ4NzMyIDUwLjMxMzUgMi4xMDk0OSA1MS4zMDQ4IDIuNTE2MDYgNTEuMzA0OEM1LjA5MDY2IDUxLjMwNDggNy45ODAzIDUwLjk5MzEgMTAuNTU2IDUwLjk5MzFDMTYuMTgyIDUwLjk5MzEgMTkuNjMwNCA1MS4yNjE5" + "IDIwLjIxNjggNTEuNDIxM0MyMS4yMDc3IDUxLjY4MjkgMjEuNjY0MyA1MC40OTAzIDIwLjU1MzEgNTAuMTEzM0MxOS4xOTUxIDQ5LjY1MzQgMTMuMTAyOSA1MC40MjggMTIuOTk3NSA0NC40NjUyVjI5LjIzNzdDMTIuOTk3NSAyOC41MTQyIDEyLjU5MzEgMjcuMjUxMSAxNC43NjExIDI3LjIxNjNDMjEuNTk0MSAyNy4xMTExIDIxLjQ2NDIgMjYuNjExNCAyMi42MzkyIDI4LjMwNDdDMjIuNjM5MiAyOC4zMDQ3IDM1LjY4MDQgNDMuNjcwMSA0Mi43NzMgNTAuMDgxNkM0OC45Njk1IDU1LjY4NDcgNTYuNjIzMSA1OS41ODMzIDYxLjU2NDcgNTkuODE3M0M2OC4yMzE2IDYwLjEyNyA3MS44MjQ4IDU5LjA0OTkgNzEuODI0OCA1OC4xODMzQzcxLjgyNDggNTYuNDUyMiA2Ni4wMDgzIDYxLjMyMjYgNTAuMTk0NSA0OS4xNDc2QzQwLjY0NzYgNDEuOCAyOC45MDE3IDI4LjAzMjggMjcuMDgwNiAyNS43NjYyQzI2LjkzMjcgMjUuNTg3NCAyNy4wOTU1IDI1LjQxNzggMjcuMDgwNiAyNS4zOTAyWk0xMy4wMDY1IDQuOTcyNUMxMy4wMDY1IDIuMTU3MTMgMTQuNjEyNSAyLjQ1ODYgMTcuMDUzIDIuNDU4NkMyMy42OTc2IDIuNDU4NiAzMC4zOTAxIDUuNjQxODUgMzAuMzkwMSAxNC40NDY2QzMwLjM5MDEgMjMuMzk2NSAyMy4zMDI3IDI0Ljc2MzggMjAuMzg4NiAyNC43NjM4QzE3LjIwMzEgMjQuNzYzOCAxMi45OTkgMjUuMTI0NiAxMi45OTkgMjMuNzU0MkwxMy4wMDY1IDQuOTcyNVoiCiAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjYuODg1IDUuMTI4OTEpIgogIC8+CjxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMjYuOTkyMyAyNS4yOTlDMzEuODM2MSAyMy4yNzg2IDM2LjkwNDQgMTguNTcxNyAzNi45MDQ0IDEyLjk0M0MzNi45MDQ0IC0wLjk4NjYyMyAyMC4xMDUxIDAuMDE1ODcxNiAxOC45OTUgMC4wMTU4NzE2QzE1LjM5NjUgMC4wMTU4NzE2IDExLjMxMjcgMC42NTk2NzUgNy4yOTcwNCAwLjY1OTY3NUMzLjc2NjY4IDAuNjU5Njc1IDIuNjMxMDUgMC4yODk3NDQgMS4zODQ3MiAwLjI4OTc0NEMwLjU1NDU1IDAuMjg5NzQ0IC0wLjAwNTI4NCAwLjcyMzAzNCAzLjc2MTI5ZS0wNSAxLjE1NjMyQzAuMDEwNjgwOCAzLjE3NTYyIDcuNTU5OTMgLTAuMDQ3NDg3MyA3LjQzNzUzIDcuNjU5NzZWNDQuMzk1NEM3LjQ2MDk1IDUwLjA1MTcgMy4zNDQxNCA0OS44NjM2IDEuOTA3MzEgNDkuODYzNkMwLjIyNzgwMyA0OS44NjM2IDIuNjU5NzggNTEuNDY1IDMuMDc0ODcgNTEuNDY1QzUuNzA0ODEgNTEuNDY1IDcuNzcxNzMgNTAuODA0OCAxMC40MDI3IDUwLjgwNDhDMTYuMTQ4IDUwLjgwNDggMjAuMDE2OCA1MS4zODQyIDIwLjYzMDkgNTEuNDg0NEMyMi4yMzI3IDUxLjc0NCAyMi41Mjc1IDUwLjU4ODIgMjAuOTg5NiA1MC4yNDE3QzE4LjUwNjUgNDkuNjc2NiAxMi45NTA3IDQ5LjczNzkgMTIuOTE4OCA0My43NTM2TDEyLjkwNiAyOS4yMzk0QzEyLjkwNiAyOC41MTcgMTIuNDkwOSAyNy4yMTkxIDE0LjcwNTggMjcuMjE5MUMyMi40NTgzIDI3LjIxOTEgMjEuODY0NCAyNi44NDYxIDI0LjM1NiAzMC44ODg4TDMyLjk0NjIgNDMuNjM2MUMzNS41NzgyIDQ3Ljc1MDMgMzguMjc2MyA1MS40MzAyIDQzLjMyOTcgNTEuNDMwMkM0My45NTM0IDUxLjQzMDIgNDguNzEwOSA1MS41MjIyIDQ4LjY2NzMgNTAuNjU1NkM0OC42MjI2IDQ5Ljc3ODggNDkuNTUwNyA0OS4yOTk1IDQ3LjUyMzEgNDkuNjEyMkM0NS44NjQ5IDQ5Ljg3MDggNDIuNjk1NCA0OC43MjUyIDM5LjQ1NDUgNDMuNTY0NkwyNi45NjA0IDI1LjczOTRDMjYuNzYzNSAyNS42MTI3IDI2Ljk5MjMgMjUuMjk5IDI2Ljk5MjMgMjUuMjk5Wk0xMy4wNDkzIDUuMDY3MTlDMTMuMDQ5MyAyLjI1Mzg3IDE0LjUwNTMgMi41MDUyNiAxNi45OTU4IDIuMzk2OTRDMjQuMjMyMiAyLjA4NTI2IDMwLjc2NzEgNS4zOTIxNiAzMC43NjcxIDE0LjE5NTlDMzAuNzY3MSAyMy4xNDQ4IDI0LjIwMjQgMjQuNzM3OSAyMS4yMjY1I" + "DI0LjczNzlDMTcuOTcwNyAyNC43Mzc5IDEzLjA1NjggMjUuMTY5MiAxMy4wNTY4IDIzLjc5NzhMMTMuMDQ5MyA1LjA2NzE5WiIKICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDgzLjE4MjEgNS4xMjY5NSkiCiAgLz4KPHBhdGgKICAgIGZpbGwtcnVsZT0iZXZlbm9kZCIKICAgIGNsaXAtcnVsZT0iZXZlbm9kZCIKICAgIGQ9Ik0zOC4xMTAyIDQyLjAxNDhDMzguODAzMSA0NC4wMTU3IDM5LjM5NDkgNDYuMzI5MyA0MC4wMjkyIDQ3LjgzODdDNDEuNDAyMiA1MS4xMDU3IDM3Ljg3NCA1Mi4yOTYzIDM2LjUwMiA1Mi4xNjk1QzM1LjM5MDkgNTIuMDY5NCAzNS42NTA2IDUzLjk4ODUgMzcuNjI0OSA1My42NDYyQzM5LjQ2NzIgNTMuMzI1MyA0MS44MTUxIDUzLjExMTcgNDMuNjkwNSA1My4xMTE3QzQ2LjUzMzMgNTMuMTExNyA0OC45MjggNTMuNjQ2MiA1MS43NzI5IDUzLjY0NjJDNTIuMjU2MSA1My42NDYyIDUzLjY4NzcgNTIuNDgzMyA1Mi40NDc3IDUyLjE2OTVDNTEuNTY4NiA1MS45NDg4IDQ4LjkxOTUgNTEuOTgwNSA0Ny4zMTY2IDQ5LjI1ODFDNDUuNzc1NSA0Ni42MzQ5IDQ0LjY5NzMgNDMuNjU2IDQzLjY1NjQgNDAuOTE0MkwyOC43NDg0IDIuNzQwNzZDMjguNTQwOSAyLjE0ODA2IDI4LjA1NTYgMCAyNy4yOTI0IDBDMjYuMzkyIDAgMjYuMTg0NSAxLjE4NTQyIDIyLjA5MzIgMTAuNTIxNkw1LjUxOTU4IDQ4LjMxMThDMy43MTQ0OSA1Mi41MzY0IDAgNTEuMTUzOCAwIDUyLjcxMDFDMCA1My4xNTU3IDAuNjY0MTM4IDUzLjY0NjIgMS4wODM0OCA1My42NDYyQzIuODg0MzIgNTMuNjQ2MiA0Ljk1NjU1IDUzLjE3NjEgNi44Mjc2MyA1My4xNzYxQzguNzcwMDMgNTMuMTc2MSAxMC43MTM1IDUzLjI0NzcgMTMuMjY2OCA1My42NTIzQzE0Ljk5ODQgNTMuOTI1MiAxNS40MDA4IDUyLjUwNzggMTMuODg4NCA1Mi4xNjk1QzExLjkyNzkgNTEuNzMxMSA4Ljk4NjA4IDUwLjg3NDggOS40MDExNyA0Ny42NDQ1QzkuOTY2MzIgNDMuMjU5NSAxMy45ODUyIDM1LjU3ODggMTMuOTg1MiAzNS41Nzg4QzE0LjY4MDIgMzQuMDI0NSAxNi4zMDEyIDMzLjUwODQgMTcuNjE2NyAzMy41MDg0SDMyLjg3NDhDMzQuODE1MSAzMy41MDg0IDM1LjE5NzIgMzQuMjMyIDM1Ljg5MjIgMzUuOTM3NUwzOC4xMTAyIDQyLjAxNDhaTTI1LjAwNzYgMTAuNTIwM0MyNS40MjI3IDkuNjMxMjUgMjUuNzY5NyA5LjYzMTI1IDI2LjExNjYgMTAuNTIwM0wzMy4zMzE3IDI5LjM4NzhDMzMuNjc4NyAzMC4zNTM1IDMzLjc0ODkgMzAuODcxNyAzMi4yMjE2IDMwLjg3MTdIMTcuNzEyN0MxNi44MTIzIDMwLjg3MTcgMTYuMzI0OSAzMC41NzUzIDE2LjgxMjMgMjkuNTM5MUwyNS4wMDc2IDEwLjUyMDNaIgogICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzUuMzMwMSAyLjk0NTMxKSIKICAvPgo8cGF0aAogICAgZmlsbC1ydWxlPSJldmVub2RkIgogICAgY2xpcC1ydWxlPSJldmVub2RkIgogICAgZD0iTTYuMjIzNzYgNDIuODUxOEM2LjIyMzc2IDQ4LjQ3NTMgMy43MTcyOCA0OS4xNDI2IDIuOTEwNTIgNDkuMzYyNEMxLjU5Mzk2IDQ5Ljc5MzYgMC4xNjY2OTggNDkuNDI2NyAwLjI5NDQxNyA1MC42Nzc2QzAuMzYzNTk4IDUxLjMyMzQgMi4xMjI5MiA1MS4yMTUxIDIuNDg3OTkgNTEuMjE1MUM1Ljg1NzY0IDUxLjIxNTEgNy45NzQ1OCA1MS4wNTU3IDExLjM0MjEgNTEuMDU1N0MxMy44MzA1IDUxLjA1NTcgMjAuMDE0MiA1MS42ODQxIDIyLjY0NzMgNTEuNjg0MUMzNi45MjEgNTEuNjg0MSAzOS45NTAxIDQxLjMyNzEgMzkuOTY4MSAzNS45MjczQzQwLjAwMDEgMjUuNjY1MyAyOC40NjYgMjQuMTQ4OCAyOC40NjYgMjMuMDY3NkMyOC40NjYgMjIuNTYzOCAzNS42NTQ0IDE5LjgyIDM1LjY1NDQgMTIuNDY0MkMzNS42NTQ0IDcuMTI1NzggMzIuMTMzNyAwLjE1NzM3NCAxOS4yNTExIDAuMTU3Mzc0QzEzLjM5NjIgMC4xNTczNzQgMTIuMjExNiAwLjY5MDgxMSA5LjI4MjYzIDAuNjkwODExQzYuMzU1NzQgMC42OTA4MTEgMi4yNTE3MSAwIDAuNzg4MjYzIDBDLT" + - "AuMDkwODY4NyAwIC0wLjUxODcyNyAwLjk0MDE1OCAxLjA0OTAyIDEuNTY5NjVDMi40MzU4NCAyLjEyNDU1IDYuMjIzNzYgMS44ODY0NSA2LjIyMzc2IDcuMzY3OTdWNDIuODUxOFpNMTEuNDg0NCAyNi4wNzg2QzExLjQ4NDQgMjUuNDI4NiAxMS4xMTgzIDI0LjQxOCAxNi43NTQ5IDI0LjQxOEMzMy41ODkzIDI0LjQxOCAzMy44ODIgMzMuOTc5IDMzLjg4MiAzOC41OTI5QzMzLjg4MiA0Mi45MTg2IDMyLjQxODYgNDkuNTcxMyAyMS4xNzgzIDQ5LjY5NTlDMTAuNzg2MiA0OS44MTE0IDExLjQ4NDQgNDcuNzUxMiAxMS40ODQ0IDQyLjU1NzlWMjYuMDc4NlpNMTEuNDg3NyA3Ljk0NUMxMS40ODc3IDMuNzYxMyAxMS4xMjM3IDIuNjI1OTUgMTUuNzMzMyAyLjUzMzk4QzI0LjU0OTEgMi4zNTkyMyAzMC43NzU0IDcuMDg0NTUgMzAuNTk1NSAxNC44NzA1QzMwLjQ5NTUgMTkuMTAxMiAyNy41ODg4IDIyLjc3NiAxNy45NDcxIDIyLjYyODhDMTEuMzYyMSAyMi41MjY2IDExLjQ4NzcgMjIuMTUyNiAxMS40ODc3IDE5Ljg0MjFWNy45NDVaIgogICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCA1LjQxNzk3KSIKICAvPgo8L3N2Zz4K\n" + - "Goldman Sachs analysts predict that the S&P 500 will have a 4.2% price gain and end at 4,700 by the end of 2024, representing a modest increase from recent levels. They believe that the U.S. economy will continue to expand at a modest pace and that earnings will rise by 5%. The forecast includes an equity market valuation at an 18 times multiple. Goldman Sachs also predicts that the 'Magnificent 7' bundle of mega-caps, including Nvidia, Microsoft, Alphabet, Apple, Amazon.com, Meta Platforms, and Tesla, will outperform the rest of the S&P 500 in 2024. They suggest that there are more attractive investment opportunities in quality stocks, growth stocks with high ROIC, and beaten-down cyclicals. According to Lombard Odier, the US economy is expected to experience a mild slowdown in 2024, leading to two interest rate cuts from the Federal Reserve. Despite slower but still positive nominal growth, corporate earnings growth is expected to remain positive into 2024, allowing room for US stocks to advance. The S&P 500's stock index has seen a rebound, supported by strong US consumer spending and cost cuts in the corporate sector. However, corporate profits in the US have already experienced a recession, recording three quarters of year-on-year declines. The article discusses the factors contributing to the apparent 'divergence' between corporate profits and the broader economy. The author expects the S&P 500 to finish 2024 around 4,800 points. The article also mentions the potential risks to the outlook for equities, including a classic economic recession in the US and the possibility of a more bullish environment with a faster recovery in US manufacturing. The article briefly touches on the relative performance of European and emerging markets. \n" + - "\n" + + "AuMDkwODY4NyAwIC0wLjUxODcyNyAwLjk0MDE1OCAxLjA0OTAyIDEuNTY5NjVDMi40MzU4NCAyLjEyNDU1IDYuMjIzNzYgMS44ODY0NSA2LjIyMzc2IDcuMzY3OTdWNDIuODUxOFpNMTEuNDg0NCAyNi4wNzg2QzExLjQ4NDQgMjUuNDI4NiAxMS4xMTgzIDI0LjQxOCAxNi43NTQ5IDI0LjQxOEMzMy41ODkzIDI0LjQxOCAzMy44ODIgMzMuOTc5IDMzLjg4MiAzOC41OTI5QzMzLjg4MiA0Mi45MTg2IDMyLjQxODYgNDkuNTcxMyAyMS4xNzgzIDQ5LjY5NTlDMTAuNzg2MiA0OS44MTE0IDExLjQ4NDQgNDcuNzUxMiAxMS40ODQ0IDQyLjU1NzlWMjYuMDc4NlpNMTEuNDg3NyA3Ljk0NUMxMS40ODc3IDMuNzYxMyAxMS4xMjM3IDIuNjI1OTUgMTUuNzMzMyAyLjUzMzk4QzI0LjU0OTEgMi4zNTkyMyAzMC43NzU0IDcuMDg0NTUgMzAuNTk1NSAxNC44NzA1QzMwLjQ5NTUgMTkuMTAxMiAyNy41ODg4IDIyLjc3NiAxNy45NDcxIDIyLjYyODhDMTEuMzYyMSAyMi41MjY2IDExLjQ4NzcgMjIuMTUyNiAxMS40ODc3IDE5Ljg0MjFWNy45NDVaIgogICAgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCA1LjQxNzk3KSIKICAvPgo8L3N2Zz4K" + + val longPost: String = + "Goldman Sachs Predicts Modest Gains for S&P 500 in 2024 ( #409962b2 , v0.09)\n" + + "\n" + image + "\n" + "#GoldmanSachs #S&P500 #stockmarket #economy #earnings \n" + "\n" + "References:\n" + @@ -56,4 +53,9 @@ class ExpandableTextCutOffCalculatorTest { fun testLostPost() { assertEquals(77, ExpandableTextCutOffCalculator.indexToCutOff(longPost)) } + + @Test + fun testImage() { + assertEquals(350, ExpandableTextCutOffCalculator.indexToCutOff(image)) + } } diff --git a/commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt similarity index 50% rename from commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt index b85ac2819..d10e06ed5 100644 --- a/commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt @@ -20,13 +20,12 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith +import kotlin.test.DefaultAsserter.assertTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) class GalleryParserTest { @Test fun testMixedImageAndVideoRenderedIndividually() { @@ -39,23 +38,23 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should have 3 paragraphs (text, image, video) - Assert.assertEquals(3, state.paragraphs.size) + assertEquals(3, state.paragraphs.size) // First paragraph is text - Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[0].words.size) + assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + assertEquals(1, state.paragraphs[0].words.size) // Second paragraph is image (rendered individually, not as gallery) - Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[1].words.size) - Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) - Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) + assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + assertEquals(1, state.paragraphs[1].words.size) + assertTrue(state.paragraphs[1].words[0] is ImageSegment) + assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) // Third paragraph is video (rendered individually) - Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[2].words.size) - Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) - Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) + assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + assertEquals(1, state.paragraphs[2].words.size) + assertTrue(state.paragraphs[2].words[0] is VideoSegment) + assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) } @Test @@ -72,27 +71,27 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should have 6 paragraphs (text, image, video, text, image, video) - Assert.assertEquals(6, state.paragraphs.size) + assertEquals(6, state.paragraphs.size) // First set: text, image, video - Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[0].words.size) - Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) - Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) - Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) - Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) - Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) - Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) + assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + assertEquals(1, state.paragraphs[0].words.size) + assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[1].words[0] is ImageSegment) + assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) + assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[2].words[0] is VideoSegment) + assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) // Second set: text, image, video - Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[3].words.size) - Assert.assertTrue(state.paragraphs[4] !is ImageGalleryParagraph) - Assert.assertTrue(state.paragraphs[4].words[0] is ImageSegment) - Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[4].words[0].segmentText) - Assert.assertTrue(state.paragraphs[5] !is ImageGalleryParagraph) - Assert.assertTrue(state.paragraphs[5].words[0] is VideoSegment) - Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[5].words[0].segmentText) + assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + assertEquals(1, state.paragraphs[3].words.size) + assertTrue(state.paragraphs[4] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[4].words[0] is ImageSegment) + assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[4].words[0].segmentText) + assertTrue(state.paragraphs[5] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[5].words[0] is VideoSegment) + assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[5].words[0].segmentText) } @Test @@ -106,13 +105,13 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should have 2 image segments (image + video URLs) - Assert.assertEquals(2, state.paragraphs.size) + assertEquals(2, state.paragraphs.size) // Should render as gallery (1 gallery with 2 images) - Assert.assertTrue("Should render 1 gallery", state.paragraphs[1] is ImageGalleryParagraph) - Assert.assertEquals("Gallery should contain 2 images", 2, state.paragraphs[1].words.size) - Assert.assertEquals("https://example.com/image1.jpg", state.paragraphs[1].words[0].segmentText) - Assert.assertEquals("https://example.com/image2.png", state.paragraphs[1].words[1].segmentText) + assertTrue("Should render 1 gallery", state.paragraphs[1] is ImageGalleryParagraph) + assertEquals(2, state.paragraphs[1].words.size, "Gallery should contain 2 images") + assertEquals("https://example.com/image1.jpg", state.paragraphs[1].words[0].segmentText) + assertEquals("https://example.com/image2.png", state.paragraphs[1].words[1].segmentText) } @Test @@ -125,13 +124,13 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should have 2 image segments (image + video URLs) - Assert.assertEquals(2, state.paragraphs.size) + assertEquals(2, state.paragraphs.size) // Should render as gallery (1 gallery with 2 images) - Assert.assertTrue("Should render 1 gallery", state.paragraphs[1] is ImageGalleryParagraph) - Assert.assertEquals("Gallery should contain 2 images", 2, state.paragraphs[1].words.size) - Assert.assertEquals("https://example.com/image1.jpg", state.paragraphs[1].words[0].segmentText) - Assert.assertEquals("https://example.com/image2.png", state.paragraphs[1].words[1].segmentText) + assertTrue("Should render 1 gallery", state.paragraphs[1] is ImageGalleryParagraph) + assertEquals(2, state.paragraphs[1].words.size, "Gallery should contain 2 images") + assertEquals("https://example.com/image1.jpg", state.paragraphs[1].words[0].segmentText) + assertEquals("https://example.com/image2.png", state.paragraphs[1].words[1].segmentText) } @Test @@ -142,9 +141,9 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should render individually (not as gallery) - Assert.assertTrue("Should render 1 individual image", state.paragraphs[1] !is ImageGalleryParagraph) - Assert.assertTrue("Should not render as gallery", state.paragraphs[0] !is ImageGalleryParagraph) - Assert.assertEquals("https://example.com/image.jpg", state.paragraphs[1].words[0].segmentText) + assertTrue("Should render 1 individual image", state.paragraphs[1] !is ImageGalleryParagraph) + assertTrue("Should not render as gallery", state.paragraphs[0] !is ImageGalleryParagraph) + assertEquals("https://example.com/image.jpg", state.paragraphs[1].words[0].segmentText) } @Test @@ -154,10 +153,10 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should render individually (not as gallery) - Assert.assertEquals("I played The Typing of the Dead a lot when I was younger, and now I just found out that there's a new take on it: The Last Sentence. Tempted!", state.paragraphs[0].words[0].segmentText) - Assert.assertTrue("Should render as gallery", state.paragraphs[1] is ImageGalleryParagraph) - Assert.assertEquals("https://relay.dergigi.com/d6a3e33b101fe219ef251ac6261c10392c2af9918c3c252d4c202016b0b4ec83.jpg", state.paragraphs[1].words[0].segmentText) - Assert.assertEquals("https://relay.dergigi.com/d60c9c562912573f214c2b1958cc20bf8913cd718d2ed9e020621e3e3120634b.jpg", state.paragraphs[1].words[1].segmentText) + assertEquals("I played The Typing of the Dead a lot when I was younger, and now I just found out that there's a new take on it: The Last Sentence. Tempted!", state.paragraphs[0].words[0].segmentText) + assertTrue("Should render as gallery", state.paragraphs[1] is ImageGalleryParagraph) + assertEquals("https://relay.dergigi.com/d6a3e33b101fe219ef251ac6261c10392c2af9918c3c252d4c202016b0b4ec83.jpg", state.paragraphs[1].words[0].segmentText) + assertEquals("https://relay.dergigi.com/d60c9c562912573f214c2b1958cc20bf8913cd718d2ed9e020621e3e3120634b.jpg", state.paragraphs[1].words[1].segmentText) } @Test @@ -171,23 +170,23 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should have 4 paragraphs (text, image, video, hashtags) - Assert.assertEquals(4, state.paragraphs.size) + assertEquals(4, state.paragraphs.size) // First paragraph is text - Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) // Second paragraph is image (rendered individually, not as gallery) - Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) - Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) - Assert.assertEquals("https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg", state.paragraphs[1].words[0].segmentText) + assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[1].words[0] is ImageSegment) + assertEquals("https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg", state.paragraphs[1].words[0].segmentText) // Third paragraph is video (rendered individually) - Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) - Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) - Assert.assertEquals("https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4", state.paragraphs[2].words[0].segmentText) + assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[2].words[0] is VideoSegment) + assertEquals("https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4", state.paragraphs[2].words[0].segmentText) // Fourth paragraph is hashtags - Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) } @Test @@ -201,25 +200,25 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) // Should have 4 paragraphs (text, image, video, hashtags) - Assert.assertEquals(4, state.paragraphs.size) + assertEquals(4, state.paragraphs.size) // First paragraph is text - Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) - Assert.assertEquals("Desperado (1995)", state.paragraphs[0].words[0].segmentText) + assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + assertEquals("Desperado (1995)", state.paragraphs[0].words[0].segmentText) // Second paragraph is image (rendered individually, not as gallery) - Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[1].words.size) - Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) - Assert.assertEquals("https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg", state.paragraphs[1].words[0].segmentText) + assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + assertEquals(1, state.paragraphs[1].words.size) + assertTrue(state.paragraphs[1].words[0] is ImageSegment) + assertEquals("https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg", state.paragraphs[1].words[0].segmentText) // Third paragraph is video (rendered individually) - Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[2].words.size) - Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) - Assert.assertEquals("https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4", state.paragraphs[2].words[0].segmentText) + assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + assertEquals(1, state.paragraphs[2].words.size) + assertTrue(state.paragraphs[2].words[0] is VideoSegment) + assertEquals("https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4", state.paragraphs[2].words[0].segmentText) // Fourth paragraph is hashtags - Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt index 3086f2828..a1b896e45 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt @@ -55,7 +55,55 @@ class RichTextParserMultibyteTest { // user@example.com should not be in urlSet assertTrue( "user@example.com should not be in urlSet", - !state.urlSet.contains("user@example.com"), + !state.urlSet.withScheme.contains("user@example.com") && !state.urlSet.withoutScheme.contains("user@example.com"), + ) + } + + @Test + fun testHttpWithoutSpaces() { + // Multibyte characters around an email address should not produce URL/Link segments + val text = + "Vitor, vocêhttp://test.com? \uD83E\uDD7A" + + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) + + assertEquals( + "Vitor, você http://test.com ? \uD83E\uDD7A", + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, + ) + } + + @Test + fun testHttpWithoutSpacesJapan() { + // Multibyte characters around an email address should not produce URL/Link segments + val text = + "Vitor, vocêhttp://test.comほげほげ" + + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) + + assertEquals( + "Vitor, você http://test.com ほげほげ", + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, + ) + } + + @Test + fun testHttpWithoutSpacesJapan2() { + // Multibyte characters around an email address should not produce URL/Link segments + val text = + "Vitor, vocêhttp://test.com。ほげほげ" + + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) + + assertEquals( + "Vitor, você http://test.com 。ほげほげ", + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, ) } @@ -65,11 +113,29 @@ class RichTextParserMultibyteTest { val text = "Vitor, você tem como colocar alguma forma de aviso se o link vai carregar uma imagem ou um vídeo? \uD83E\uDD7A" - val regex = Regex("(?<=[\\u0000-\\u00FF])(?=[\\u0100-\\uFFFF])|(?<=[\\u0100-\\uFFFF])(?=[\\u0000-\\u00FF])| +") + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) assertEquals( - "Vitor,-você-tem-como-colocar-alguma-forma-de-aviso-se-o-link-vai-carregar-uma-imagem-ou-um-vídeo?-\uD83E\uDD7A", - text.split(regex).joinToString("-"), + "Vitor, você tem como colocar alguma forma de aviso se o link vai carregar uma imagem ou um vídeo? \uD83E\uDD7A", + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, + ) + } + + @Test + fun testFullTextWithMultibyteAndQuotes() { + // Multibyte characters around an email address should not produce URL/Link segments + val text = + "I’ve been thinking lately about how I believe there will more than likely be models unattainable by most. Think Bloomberg Terminal. Where their cost of tokens is too high for the average lay person, but their level of “cognition” is unmatched by anything else. I’m sure there will even be many closed models that are invite only. Crazy times ahead." + + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) + + assertEquals( + "I’ve been thinking lately about how I believe there will more than likely be models unattainable by most. Think Bloomberg Terminal. Where their cost of tokens is too high for the average lay person, but their level of “cognition” is unmatched by anything else. I’m sure there will even be many closed models that are invite only. Crazy times ahead.", + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, ) } @@ -93,7 +159,7 @@ class RichTextParserMultibyteTest { val urlSegments = allSegments.filterIsInstance() assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) - assertTrue("URL should be example.com", urlSegments.any { it.url == "example.com" }) + assertTrue("URL should be example.com", urlSegments.any { it.segmentText == "example.com" }) val textSegments = allSegments.filterIsInstance() assertTrue("Should have prefix ああ", textSegments.any { it.segmentText == "ああ" }) @@ -108,7 +174,7 @@ class RichTextParserMultibyteTest { val urlSegments = allSegments.filterIsInstance() assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) - assertTrue("URL should be example.com", urlSegments.any { it.url == "example.com" }) + assertTrue("URL should be example.com", urlSegments.any { it.segmentText == "example.com" }) val textSegments = allSegments.filterIsInstance() assertTrue("Should have suffix ああ", textSegments.any { it.segmentText == "ああ" }) diff --git a/commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt similarity index 94% rename from commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index 8ef9b0287..0fc584c52 100644 --- a/commons/src/androidDeviceTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -20,14 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists -import junit.framework.TestCase.assertEquals -import org.junit.Test -import org.junit.runner.RunWith +import junit.framework.TestCase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) class RichTextParserTest { private val textToParse = """ @@ -684,17 +683,34 @@ class RichTextParserTest { npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7 """.trimIndent() + @Test + fun testUrlsToParse() { + val state = + RichTextParser() + .parseText(textToParse, EmptyTagList, null) + TestCase.assertEquals( + "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, miceliomad@miceliomad.github.io/nostr/, zapper.lol, smies.me, baller.hodl", + state.urlSet.withoutScheme.joinToString(", "), + ) + + TestCase.assertEquals( + "jb55@jb55.com, Snowden@Nostr-Check.com, cameri@elder.nostr.land, natalie@NostrVerified.com, alanbwt@nostrplebs.com, rick@no.str.cr, shawn@shawnyeager.com, 0xtr@oxtr.dev, pavol@rusnak.io, caitlin@nostrverified.com, ralf@snort.social, stacksats@nostrplebs.com, MrHodl@nostrpurple.com, _@mikedilger.com, jascha@relayable.org, Nakadaimon@nostrplebs.com, KeithMukai@nostr.seedsigner.com, theguyswann@NostrVerified.com, dk@stacker.news, npub1z7eqn5603ltuxr77w70t3sasep8hyngzr6lxqpa9hfcqjwe9wmdqhw0qhv@nost.vip, miljan@primal.net, jared@nostrplebs.com, radii@orangepill.dev, _@katieannbaker.com, giacomozucco@BitcoinNostr.com, kr@stacker.news, phil@nostrpurple.com, angela@nostr.world, mason@lacosanostr.com, lau@nostr.report, damascusrex@iris.to, nym@nostr.fan, nico@nostrplebs.com, seekerdreamer1@stacker.news, thesamecat@iris.to, nitesh@noderunner.wtf, gpt3@jb55.com, byzantine@stacker.news, wealththeory@nostrplebs.com, gug@nostrplebs.com, lana@b.tc, shevacai@nostrplebs.com, joe@nostrpurple.com, simplestbitcoinbook@nostrplebs.com, knutsvanholm@iris.to, rs@zbd.ai, GRANTGILLIAM@grantgilliam.com, lifeloveliberty@iris.to, npub1s9c53smfq925qx6fgkqgw8as2e99l2hmj32gz0hjjhe8q67fxdvs3ga9je@nost.vip, arbedout@granddecentral.com, glowleaf@nostrplebs.com, modus@lacosanostr.com, anil@bitcoinnostr.com, documentingbtc@uselessshit.co, wolfbearclaw@nostr.messagepush.io, _@amboss.space, k3tan@k3tan.com, wolzie@BitcoinNostr.com, trey@nostrplebs.com, woody@fountain.fm, cosmicdimension@nostrplebs.com, mirbtc@getalby.com, marks@nostrplebs.com, barrensatin40@walletofsatoshi.com, alexemidio@alexemidio.github.io, Jenn@mintgreen.co, spacemonkey@nostrich.love, ishak@nostrplebs.com, GrassFedBitcoin@start9.com, ninoholds@nostrplebs.com, satcap@nostr.satcap.io, tpmoreira@nostrplebs.com, force2b@nostrplebs.com, hendrix@nostrplebs.com, TXMC@alphabetasoup.tv, pipleb@iris.to, reallhex@terranostr.com, nicb@nicb.me, NabismoPrime@BostonBTC.com, paco@iris.to, globalstatesmen@nostrplebs.com, _@NostrNet.work, crayonsmell@habel.net, ToxiKat27@Bitcoiner.social, jtrag@BitcoinNostr.com, joemartinmusic@nostrplebs.com, ph@nostrplebs.com, horse@iris.to, kp@no.str.cr, rebornbitcoiner@getalby.com, toshi@nostr-check.com, freeborn@nostrplebs.com, blee@bitcoiner.social, SatsTonight@BitcoinNostr.com, freeverification@Nostr-Check.com, cowmaster@getalby.com, hacker818@iris.to, amandabitcasa@nostrplebs.com, tiago@nostrplebs.com, sepehr@nostribe.com, gfy@stacker.news, cryptojournaal@iris.to, bon@nostrplebs.com, bot@binarywatch.org, moritz@getalby.com, hodlish@Nostr-Check.com, HolgerHatGarKeineNode@nip05.easify.de, joe@jaxo.github.io, hahattpro@iris.to, bensima@simatime.com, satan@nostrcheck.me, radvladdy@nostrplebs.com, yidneth@getalby.com, bellatrix@iris.to, securecoop@iris.to, charliesurf@ln.tips, bitcoinatm@Nostr-Check.com, lnstallone@allmysats.com, L0laL33tz@cashu.me, Lommy@nostrplebs.com, jgmontoya@nostrplebs.com, bavarianledger@iris.to, operator@brb.io, tiotito@nostriches.net, javi@www.javiergonzalez.io, geekigai@nostrplebs.com, Mr.Rabbit@BitcoinNostr.com, kilicl@nostr-check.com, alexbit@nostrbr.online, william@nostrplebs.com, oneezra@nostrplebs.com, iceandfirebtc@nostrplebs.com, nostrgang@nostrplebs.com, npub1vez5zekuzc3qk989q5gtly2zg9k2gz4l3wuplv5xs8y3se09yussg4vp7p@carteclip.com, Sqvaznyak@uselessshit.co, braj@nostrplebs.com, libertus@getalby.com, ZoeBoudreault@id.nostrfy.me, dnilso@iris.to, shroom@nostrplebs.com, olegaba@olegaba.com, wasabi@nostrplebs.com, artur@getalby.com, ihsanmd@getalby.com, satoshee@vida.page, ancapsu@getalby.com, niceaction@www.niceaction.com, seak@nostrplebs.com, andy@nodeless.io, pinkyjay@nostrplebs.com, criptobastardo@nostrplebs.com, lacosanostr@lacosanostr.com, teejem@nostrplebs.com, mewj@elder.nostr.land, thetonewrecker@nostrplebs.com, yodatravels@iris.to, bitcoin69@iris.to, Zzar@nostrplebs.com, vidalbidi@getalby.com, juangalt@current.ninja, dean@nostrplebs.com, alex2@nostrverified.com, trooper@iris.to, satscoinsv@getalby.com, aarbtc@iris.to, _@gue.yogsite.com, nostrmemes@iris.to, btcpavao@iris.to, Anonymous@BitcoinNostr.com, zoltanab@iris.to, katsu@onsats.org, bryan@nonni.io, pedromvpg@pedromvpg.com, sonicstudio@getalby.com, kounsellor@nostrplebs.com, katieross@nostrplebs.com, iamlj@iris.to, Zach@BitcoinNostr.com, amouranth@nostrcheck.me, hss5qy@getalby.com, dpcpw@iris.to, bitcoinfinity@nostrplebs.com, TonySeries@BitcoinNostr.com, kuobano@nostrplebs.com, kitakripto@BitcoinNostr.com, _@localhost.re, alxc@uselessshit.co, kukryr@orangepill.dev, saidah@nostrplebs.com, sharon21m@nostr.fan, dmn@noderunners.org, nela_at_nostrica2023@Nostr-Check.com, xbologg@nanostr.deno.dev, btcurenas@nostr.fan, amaluenda@getalby.com, queenb@nostrplebs.com, ezekiel@Nostr-Check.com, marakesh@getalby.com, storm@reddirtmining.io, _b_o_n_e_s_@stacker.news, bdichdbd@stacker.news, j9@nostrplebs.com, nokyctranslate@iris.to, Neomobius_at_mstdn.jp@mostr.pub, paddepadde@getcurrent.io, val@nostrplebs.com, dishwasher_iot@wlvs.space, 1@justinrezvani.com, sshh@nostrplebs.com, mk05@iris.to, herald@bitcoin-herald.org, nostr@pos.btcpayserver.it, thumpgofast@NostrVerified.com, neo@elder.nostr.land, alchemist@electronalchemy.com, timp@iris.to, ken@BitcoinNostr.com, irebus@nostr.red, stim4444@no.str.cr, layerlnw@nostr.fan, enhickman@enhickman.net, LiveFreeBTC@livefreebtc.org, aptx4869@aptx4869.app, khalil@klouche.com, nsec@ittybitty.tips, bigfish@iris.to, oldschool@iris.to, danton@nostrplebs.com, bitcoinzavior@nostrplebs.com, BitcoinSermons@BitcoinNostr.com, uknwlinux@plebs.place, satoshism@nostrplebs.com, jon@nostrplebs.com, hobozakki@nostrplebs.com, sirgalahodl@satstream.me, victor@lnmarkets.com, jacksmies@iris.to, chemaclass@snort.social, jayson@tautic.com, jesterhodl@jesterhodl.com, Bitcoin_Gamer_21@bitcoin-21.org, water-bot@gourcetools.github.io, w3crypto@iris.to, makaveli@nostrplebs.com, jamieanders@ln.tips, cball@nostrplebs.com, laura@nostrich.zone, kaz@reddirtmining.io, verismus@nostrplebs.com, bitpetro@nostrplebs.com, nossence@nossence.xyz, manbearpig@nostrplebs.com, johnsmith@nostrplebs.com, bitcoin@bitcoinedu.com, knggolf@nostrplebs.com, nighthaven@iris.to, xbt_fi@iris.to, gencarlosq17@iris.to, rotciv@plebs.place, Merlin@bitcoinnostr.com, yeg0rpetrov@iris.to, baloo@nostrpurple.com, jamesgospodyn@nostr.theorangepillapp.com, carl@armadalabs.studio, btcportal@nostrplebs.com, mjb@nostrplebs.com, esbewolkt@nostr.fan, koukos@iris.to, davekrock@NostrVerified.com, Bitcoinlovelife@BitcoinNostr.com, xolagl2@getalby.com, remoney@nostrplebs.com, roberto@bitcoiner.chat, cryptolazyninja@stacker.news, kimymt@getalby.com, benderlogic@rogue.earth, MAESTRO@BitcoinNostr.com, travis@west.report, coffeelover@nostrplebs.com, shadowysuperstore@shadowysuperstore.com, npub1wg2dsjnh0g7phheq23v288k0mj8x75fffmq7rghtkhv53027hnassf4w8t@nost.vip, eynhaender@nostrplebs.com, b1ackswan@nostrplebs.com, mrbitc0in@nostrplebs.com, jedi@nostrplebs.com, cloudnull@nostrplebs.com, Mrwh0@Mrwh0.github.io, shinohai@iris.to, awoi@iris.to, jcope101@nostrplebs.com, murmur@nostrplebs.com, dario@nostrplebs.com, leonawankum@BitcoinNostr.com, phil@iris.to, thatirdude@nostrplebs.com, K_hole@ketamine.com, 2@lokuyow.github.io, hazey@iris.to, HeineNon@tomottodx.github.io, titan@nostrplebs.com", + state.urlSet.emails.joinToString(", "), + ) + + TestCase.assertEquals( + "", + state.urlSet.withScheme.joinToString(", "), + ) + } + @Test fun testTextToParse() { val state = RichTextParser() .parseText(textToParse, EmptyTagList, null) - assertEquals( - "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, ⚡\uFE0Fsatscoinsv@getalby.com, miceliomad@miceliomad.github.io/nostr/, zapper.lol, smies.me, baller.hodl", - state.urlSet.joinToString(", "), - ) - printStateForDebug(state) + // printStateForDebug(state) val expectedResult = listOf( @@ -733,52 +749,52 @@ class RichTextParserTest { "RegularText(Events (%) Relay)", "RegularText(33.4K)", "RegularText((4.6%))", - "Link(relay.shitforce.one)", + "SchemelessUrl(relay.shitforce.one)", "RegularText(32.9K)", "RegularText((4.6%))", - "Link(relayable.org)", + "SchemelessUrl(relayable.org)", "RegularText(26.6K)", "RegularText((3.7%))", - "Link(universe.nostrich.land)", + "SchemelessUrl(universe.nostrich.land)", "RegularText(22.8K)", "RegularText((3.2%))", - "Link(nos.lol)", + "SchemelessUrl(nos.lol)", "RegularText(22.7K)", "RegularText((3.1%))", - "Link(universe.nostrich.land?lang=zh)", + "SchemelessUrl(universe.nostrich.land?lang=zh)", "RegularText(22.5K)", "RegularText((3.1%))", - "Link(universe.nostrich.land?lang=en)", + "SchemelessUrl(universe.nostrich.land?lang=en)", "RegularText(21.2K)", "RegularText((2.9%))", - "Link(relay.damus.io)", + "SchemelessUrl(relay.damus.io)", "RegularText(20.6K)", "RegularText((2.9%))", - "Link(relay.nostr.wirednet.jp)", + "SchemelessUrl(relay.nostr.wirednet.jp)", "RegularText(20.1K)", "RegularText((2.8%))", - "Link(offchain.pub)", + "SchemelessUrl(offchain.pub)", "RegularText(19.9K)", "RegularText((2.8%))", - "Link(nostr.rocks)", + "SchemelessUrl(nostr.rocks)", "RegularText(19.5K)", "RegularText((2.7%))", - "Link(relay.wellorder.net)", + "SchemelessUrl(relay.wellorder.net)", "RegularText(19.4K)", "RegularText((2.7%))", - "Link(nostr.oxtr.dev)", + "SchemelessUrl(nostr.oxtr.dev)", "RegularText(19K)", "RegularText((2.6%))", - "Link(universe.nostrich.land?lang=ja)", + "SchemelessUrl(universe.nostrich.land?lang=ja)", "RegularText(18.4K)", "RegularText((2.6%))", - "Link(relay.mostr.pub)", + "SchemelessUrl(relay.mostr.pub)", "RegularText(17.5K)", "RegularText((2.4%))", - "Link(universe.nostrich.land?lang=zh)", + "SchemelessUrl(universe.nostrich.land?lang=zh)", "RegularText(16.3K)", "RegularText((2.3%))", - "Link(nostr.bitcoiner.social)", + "SchemelessUrl(nostr.bitcoiner.social)", "RegularText()", "RegularText(30 day global new events)", "RegularText()", @@ -1617,7 +1633,8 @@ class RichTextParserTest { "RegularText(eb3b94533dafeb8ebd58a4947a3dce11d83a9931c622bdf30a4257d3347ee1bf)", "HashTag(#109)", "RegularText(3%)", - "SchemelessUrl(Nostr-Check.com,)", + "SchemelessUrl(Nostr-Check.com)", + "RegularText(,)", "Email(freeverification@Nostr-Check.com)", "RegularText(-)", "RegularText(ddfbb06a722e51933cd37e4ecdb30b1864f262f9bb5bd6c2d95cbeefc728f096)", @@ -1893,7 +1910,8 @@ class RichTextParserTest { "RegularText(d162a53c3b0bfb5c3ebd787d7b08feab206b112362eca25aa291251cd70fe225)", "HashTag(#154)", "RegularText(3%)", - "SchemelessUrl(MR.Rabbit,)", + "SchemelessUrl(MR.Rabbit)", + "RegularText(,)", "Email(Mr.Rabbit@BitcoinNostr.com)", "RegularText(-)", "RegularText(42af69b2384071f31e55cb2d368c8a3351c8f2da03207e1fb6885991ac2522bf)", @@ -2069,7 +2087,8 @@ class RichTextParserTest { "RegularText(39ed0aea2338477103e0b5a820532ded27dbfe4f203e7270392d55f63e60271a)", "HashTag(#183)", "RegularText(2%)", - "SchemelessUrl(Ancap.su,)", + "SchemelessUrl(Ancap.su)", + "RegularText(,)", "Email(ancapsu@getalby.com)", "RegularText(-)", "RegularText(2fe5292a2df25047a392fceead75458875c775c31cc28f4be04cef3e8db15291)", @@ -2258,7 +2277,8 @@ class RichTextParserTest { "HashTag(#214)", "RegularText(2%)", "RegularText(Satscoinsv,)", - "Link(⚡️satscoinsv@getalby.com)", + "RegularText(⚡\uFE0F)", + "Email(satscoinsv@getalby.com)", "RegularText(-)", "RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)", "HashTag(#215)", @@ -2468,7 +2488,7 @@ class RichTextParserTest { "HashTag(#249)", "RegularText(2%)", "RegularText(micmad,)", - "Link(miceliomad@miceliomad.github.io/nostr/)", + "SchemelessUrl(miceliomad@miceliomad.github.io/nostr/)", "RegularText(-)", "RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)", "HashTag(#250)", @@ -2647,7 +2667,7 @@ class RichTextParserTest { "HashTag(#278)", "RegularText(2%)", "RegularText(𝕬𝖓𝖔𝖓𝖞𝖒𝖔𝖚𝖘,)", - "Link(zapper.lol)", + "SchemelessUrl(zapper.lol)", "RegularText(-)", "RegularText(96aceca84aa381eeda084167dd317e1bf7a45d874cd14147f0a9e0df86fb44c2)", "HashTag(#279)", @@ -3105,7 +3125,8 @@ class RichTextParserTest { "RegularText(c4a9caef93e93f484274c04cd981d1de1424902451aca2f5602bd0835fe4393d)", "HashTag(#354)", "RegularText(2%)", - "SchemelessUrl(smies.me,)", + "SchemelessUrl(smies.me)", + "RegularText(,)", "Email(jacksmies@iris.to)", "RegularText(-)", "RegularText(cdecbc48e35a351582e3e030fd8cf5d5f44681613d2949353d9c6644d32d451f)", @@ -3815,7 +3836,8 @@ class RichTextParserTest { "HashTag(#471)", "RegularText(1%)", "RegularText(leonwankum,)", - "RegularText(@leonawankum@BitcoinNostr.com)", + "RegularText(@)", + "Email(leonawankum@BitcoinNostr.com)", "RegularText()", "RegularText(-)", "RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)", @@ -3984,7 +4006,8 @@ class RichTextParserTest { "RegularText(21a7014db2ba17acc8bbb9496645084866b46e1ba0062a80513afda405450183)", "HashTag(#499)", "RegularText(1%)", - "SchemelessUrl(baller.hodl,)", + "SchemelessUrl(baller.hodl)", + "RegularText(,)", "RegularText()", "RegularText(-)", "RegularText(d8150dc0631f834a004f231f0747d5ec8409b1a9214d246f675dfef39807a224)", @@ -4021,16 +4044,16 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - org.junit.Assert.assertEquals( + assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) } - org.junit.Assert.assertTrue(state.imagesForPager.isEmpty()) - org.junit.Assert.assertTrue(state.imageList.isEmpty()) - org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) - org.junit.Assert.assertEquals(651, state.paragraphs.size) + assertTrue(state.imagesForPager.isEmpty()) + assertTrue(state.imageList.isEmpty()) + assertTrue(state.customEmoji.isEmpty()) + assertEquals(651, state.paragraphs.size) } @Test @@ -4038,11 +4061,13 @@ class RichTextParserTest { val state = RichTextParser() .parseText("Hi, how are you doing? ", EmptyTagList, null) - org.junit.Assert.assertTrue(state.urlSet.isEmpty()) - org.junit.Assert.assertTrue(state.imagesForPager.isEmpty()) - org.junit.Assert.assertTrue(state.imageList.isEmpty()) - org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) - org.junit.Assert.assertEquals( + assertTrue(state.urlSet.withoutScheme.isEmpty()) + assertTrue(state.urlSet.withScheme.isEmpty()) + assertTrue(state.urlSet.emails.isEmpty()) + assertTrue(state.imagesForPager.isEmpty()) + assertTrue(state.imageList.isEmpty()) + assertTrue(state.customEmoji.isEmpty()) + assertEquals( "Hi, how are you doing?", state.paragraphs .firstOrNull() @@ -4056,11 +4081,13 @@ class RichTextParserTest { fun testShortNewLinesTextToParse() { val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList, null) - org.junit.Assert.assertTrue(state.urlSet.isEmpty()) - org.junit.Assert.assertTrue(state.imagesForPager.isEmpty()) - org.junit.Assert.assertTrue(state.imageList.isEmpty()) - org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) - org.junit.Assert.assertEquals( + assertTrue(state.urlSet.withoutScheme.isEmpty()) + assertTrue(state.urlSet.withScheme.isEmpty()) + assertTrue(state.urlSet.emails.isEmpty()) + assertTrue(state.imagesForPager.isEmpty()) + assertTrue(state.imageList.isEmpty()) + assertTrue(state.customEmoji.isEmpty()) + assertEquals( "\nHi,\nhow\n\n\n are you doing?\n", state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }, ) @@ -4080,19 +4107,19 @@ class RichTextParserTest { val state = RichTextParser() .parseText(text, EmptyTagList, null) - org.junit.Assert.assertEquals( + assertEquals( "https://lnshort.it/live-stream-embeds/", - state.urlSet.firstOrNull(), + state.urlSet.withScheme.firstOrNull(), ) - org.junit.Assert.assertEquals( + assertEquals( "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imagesForPager.keys.firstOrNull(), ) - org.junit.Assert.assertEquals( + assertEquals( "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imageList.firstOrNull()?.url, ) - org.junit.Assert.assertTrue(state.customEmoji.isEmpty()) + assertTrue(state.customEmoji.isEmpty()) printStateForDebug(state) @@ -4142,7 +4169,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - org.junit.Assert.assertEquals( + assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4175,7 +4202,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - org.junit.Assert.assertEquals( + assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4207,7 +4234,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - org.junit.Assert.assertEquals( + assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4231,7 +4258,7 @@ class RichTextParserTest { val expectedResult = listOf( "RegularText(Goon Night everybody :sleep:)", - "Image(81ca16-b665-4f57-80cb-11a58461fb61.avif)", + "Image(https://81ca16-b665-4f57-80cb-11a58461fb61.avif)", "Image(https://bae.st/media/66b08dde784287ed8f92c455bc62076a04671ccb44097550626a532185a5d3ed.avif?name=81ca16-b665-4f57-80cb-11a58461fb61.avif)", ) @@ -4239,7 +4266,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - org.junit.Assert.assertEquals( + assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4260,7 +4287,8 @@ class RichTextParserTest { listOf( "RegularText(That’s)", "RegularText(it!)", - "Link(http://vitorpamplona.com/.)", + "Link(http://vitorpamplona.com/)", + "RegularText(.)", "RegularText(That’s)", "RegularText(the)", "RegularText(note)", @@ -4270,7 +4298,52 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - org.junit.Assert.assertEquals( + assertEquals( + expectedResult[index], + "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", + ) + } + } + + @Test + fun testRelayUrls() { + val text = "If you want to FreeFrom Official \uD80C\uDD66 stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm at wss://relay.damus.io can you here" + + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) + + printStateForDebug(state) + + val expectedResult = + listOf( + "RegularText(If)", + "RegularText(you)", + "RegularText(want)", + "RegularText(to)", + "RegularText(FreeFrom)", + "RegularText(Official)", + "RegularText(𓅦)", + "RegularText(stream)", + "RegularText(or)", + "RegularText(download)", + "RegularText(the)", + "RegularText(music)", + "RegularText(from)", + "RegularText()", + "Bech(nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm)", + "RegularText(at)", + "RelayUrl(wss://relay.damus.io)", + "RegularText(can)", + "RegularText(you)", + "RegularText(here)", + ) + + state.paragraphs + .map { it.words } + .flatten() + .forEachIndexed { index, seg -> + assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) @@ -4308,7 +4381,7 @@ class RichTextParserTest { .map { it.words } .flatten() .forEachIndexed { index, seg -> - org.junit.Assert.assertEquals( + assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})", ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt new file mode 100644 index 000000000..ce27e3ef4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -0,0 +1,294 @@ +/* + * 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.commons.richtext + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UrlParserTest { + val parser = UrlParser() + + fun test( + text: String, + expected: Urls, + ) { + val urlSet = parser.parseValidUrls(text) + assertEquals(expected.withScheme, urlSet.withScheme) + assertEquals(expected.withoutScheme, urlSet.withoutScheme) + assertEquals(expected.emails, urlSet.emails) + } + + @Test + fun testSimpleText() = + test( + "test. com", + Urls(), + ) + + @Test + fun testBasicUrl() = + test( + "http://test.com", + Urls(withScheme = setOf("http://test.com")), + ) + + @Test + fun testNoSchemaUrl() = + test( + "test.com", + Urls(withoutScheme = setOf("test.com")), + ) + + @Test + fun testNoSchemaUrlPrefixMultibyte() = + test( + "ほtest.com", + Urls(withoutScheme = setOf("test.com")), + ) + + @Test + fun testNoSchemaUrlSuffixMultibyte() = + test( + "test.comほ", + Urls(withoutScheme = setOf("test.com")), + ) + + @Test + fun testNoSchemaUrlWithParams() = + test( + "test.com/some/me/hey?param=value#some=value", + Urls(withoutScheme = setOf("test.com/some/me/hey?param=value#some=value")), + ) + + @Test + fun testNoSchemaUrlWithParamsWithOtherWords() = + test( + "Hi there, check my website test.com/some/me/hey?param=value#some=value .", + Urls(withoutScheme = setOf("test.com/some/me/hey?param=value#some=value")), + ) + + @Test + fun testBasicUrlWithoutSpaceBefore() = + test( + "ahttp://test.com", + Urls(withScheme = setOf("http://test.com")), + ) + + @Test + fun testBasicUrlWithoutSpaceBeforeMultiByte() = + test( + "ほhttp://test.com", + Urls(withScheme = setOf("http://test.com")), + ) + + @Test + fun testBasicUrlWithoutSpaceAfter() = + test( + "http://test.comほ", + Urls(withScheme = setOf("http://test.com")), + ) + + @Test + fun testBasicUrlWithMultibytePath() = + test( + "http://test.com/ほ", + Urls(withScheme = setOf("http://test.com/ほ")), + ) + + @Test + fun testBasicUrls() = + test( + "http://test.com http://test2.com", + Urls(withScheme = setOf("http://test.com", "http://test2.com")), + ) + + @Test + fun testEmail() = + test( + "vitor@vitorpamplona.com", + Urls(emails = setOf("vitor@vitorpamplona.com")), + ) + + @Test + fun testEmailWithMultibytePrefix() = + test( + "ほvitor@vitorpamplona.com", + Urls(emails = setOf("vitor@vitorpamplona.com")), + ) + + @Test + fun testEmailWithMultibyteSuffix() = + test( + "vitor@vitorpamplona.comほ", + Urls(emails = setOf("vitor@vitorpamplona.com")), + ) + + @Test + fun testEmailWithMultibyteBoth() = + test( + "ほvitor@vitorpamplona.comほ", + Urls(emails = setOf("vitor@vitorpamplona.com")), + ) + + @Test + fun testUrlWithUserAndMultibyteSuffix() = + test( + "http://vitor@vitorpamplona.comほ", + Urls(withScheme = setOf("http://vitor@vitorpamplona.com")), + ) + + @Test + fun testUrlWithUserAndMultibytePrefix() = + test( + "ほhttp://vitor@vitorpamplona.com", + Urls(withScheme = setOf("http://vitor@vitorpamplona.com")), + ) + + @Test + fun testNostrUrls() = + test( + "nostr:npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0", + Urls(bech32s = setOf("nostr:npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0")), + ) + + @Test + fun testUrlsWithUsernameAndPath() = + test( + "miceliomad@miceliomad.github.io/nostr/", + Urls(withoutScheme = setOf("miceliomad@miceliomad.github.io/nostr/")), + ) + + @Test + fun testUrlsWithQuery() = + test( + " universe.nostrich.land?lang=zh ", + Urls(withoutScheme = setOf("universe.nostrich.land?lang=zh")), + ) + + @Test + fun testUrlsWithSchemaPathAndQuery() = + test( + "https://miceliomad.github.io/nostr/test?me=you", + Urls(withScheme = setOf("https://miceliomad.github.io/nostr/test?me=you")), + ) + + @Test + fun testUrlsWithPathAndQuery() = + test( + "miceliomad.github.io/nostr/test?me=you", + Urls(withoutScheme = setOf("miceliomad.github.io/nostr/test?me=you")), + ) + + @Test + fun testAvifFileNameComplete() = + test( + "https://bae.st/media/66b08dde784287ed8f92c455bc62076a04671ccb44097550626a532185a5d3ed.avif?name=81ca16-b665-4f57-80cb-11a58461fb61.avif", + Urls(withScheme = setOf("https://bae.st/media/66b08dde784287ed8f92c455bc62076a04671ccb44097550626a532185a5d3ed.avif?name=81ca16-b665-4f57-80cb-11a58461fb61.avif")), + ) + + @Test + fun testAvifFileName() = + test( + "81ca16-b665-4f57-80cb-11a58461fb61.avif", + Urls(withoutScheme = setOf("81ca16-b665-4f57-80cb-11a58461fb61.avif")), + ) + + @Test + fun testMultiLine() = + test( + """ + 22.8K (3.2%) nos.lol + 22.7K (3.1%) universe.nostrich.land?lang=zh + 22.5K (3.1%) universe.nostrich.land?lang=en + """.trimIndent(), + Urls(withoutScheme = setOf("nos.lol", "universe.nostrich.land?lang=zh", "universe.nostrich.land?lang=en")), + ) + + @Test + fun testEmailWithDashes() = + test( + "freeverification@Nostr-Check.com", + Urls(emails = setOf("freeverification@Nostr-Check.com")), + ) + + @Test + fun testEmailWithManyDashes() = + test( + "free-veri-fica-tion@No-str-Ch-eck.com", + Urls(emails = setOf("free-veri-fica-tion@No-str-Ch-eck.com")), + ) + + @Test + fun testEmailWithUnderscore() = + test( + "vi_t_or@vitorpamplona.com", + Urls(emails = setOf("vi_t_or@vitorpamplona.com")), + ) + + @Test + fun testEmailsWithPeriod() = + test( + "john.smith@gmail.com", + Urls(emails = setOf("john.smith@gmail.com")), + ) + + @Test + fun testStrangeError() = + test( + "neomobius_at_mstdn.jp@mostr.pub", + Urls(emails = setOf("neomobius_at_mstdn.jp@mostr.pub")), + ) + + @Test + fun testRelayUrl() = + test( + "wss://test.com", + Urls(relayUrls = setOf("wss://test.com")), + ) + + @Test + fun testBech12() = + test( + "nostr:npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0", + Urls(bech32s = setOf("nostr:npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0")), + ) + + @Test + fun testJapaneseUrls() = + test( + "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", + Urls(withScheme = emptySet()), + ) + + @Test + fun testHour() = + test( + "10.00hr,", + Urls(withScheme = emptySet()), + ) + + @Test + fun testBlossom() = + test( + "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth", + Urls(withScheme = setOf("blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth")), + ) +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 40f6e6d8c..85c1c5607 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -49,9 +49,16 @@ dependencies { // Collections implementation(libs.kotlinx.collections.immutable) + // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps + implementation("org.slf4j:slf4j-nop:2.0.16") + + // QR code generation (ZXing core) + implementation(libs.zxing) + // Testing testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) testImplementation(libs.okhttp) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fa3abb3e2..30470359f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -72,11 +72,14 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog +import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType @@ -86,12 +89,15 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -104,21 +110,21 @@ enum class LayoutMode { * Desktop navigation state — used for in-column navigation (drill-down). */ sealed class DesktopScreen { - object Feed : DesktopScreen() + data object Feed : DesktopScreen() - object Reads : DesktopScreen() + data object Reads : DesktopScreen() - object Search : DesktopScreen() + data object Search : DesktopScreen() - object Bookmarks : DesktopScreen() + data object Bookmarks : DesktopScreen() - object Messages : DesktopScreen() + data object Messages : DesktopScreen() - object Notifications : DesktopScreen() + data object Notifications : DesktopScreen() - object Chess : DesktopScreen() + data object Chess : DesktopScreen() - object MyProfile : DesktopScreen() + data object MyProfile : DesktopScreen() data class UserProfile( val pubKeyHex: String, @@ -128,7 +134,7 @@ sealed class DesktopScreen { val noteId: String, ) : DesktopScreen() - object Settings : DesktopScreen() + data object Settings : DesktopScreen() } fun main() = @@ -143,6 +149,8 @@ fun main() = var replyToNote by remember { mutableStateOf(null) } val deckScope = rememberCoroutineScope() val deckState = remember { DeckState(deckScope).also { it.load() } } + val accountManager = remember { AccountManager.create() } + val accountState by accountManager.accountState.collectAsState() var showAddColumnDialog by remember { mutableStateOf(false) } var layoutMode by remember { mutableStateOf( @@ -189,6 +197,16 @@ fun main() = }, ) Separator() + Item( + "Logout", + onClick = { + deckScope.launch { + accountManager.logout(deleteKey = true) + } + }, + enabled = accountState is AccountState.LoggedIn, + ) + Separator() Item( "Quit", shortcut = @@ -348,6 +366,7 @@ fun main() = App( layoutMode = layoutMode, deckState = deckState, + accountManager = accountManager, showComposeDialog = showComposeDialog, showAddColumnDialog = showAddColumnDialog, onShowComposeDialog = { showComposeDialog = true }, @@ -370,6 +389,7 @@ fun main() = fun App( layoutMode: LayoutMode, deckState: DeckState, + accountManager: AccountManager, showComposeDialog: Boolean, showAddColumnDialog: Boolean, onShowComposeDialog: () -> Unit, @@ -381,37 +401,54 @@ fun App( ) { val relayManager = remember { DesktopRelayConnectionManager() } val localCache = remember { DesktopLocalCache() } - val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } - // Subscriptions coordinator for metadata/reactions loading + // Subscriptions coordinator — uses default relay URLs for metadata indexing. + // Feed subscriptions (inside MainContent) drive actual relay pool connections. val subscriptionsCoordinator = remember(relayManager, localCache) { DesktopRelaySubscriptionsCoordinator( client = relayManager.client, scope = scope, - indexRelays = relayManager.availableRelays.value, + indexRelays = + DefaultRelays.RELAYS + .mapNotNull { + RelayUrlNormalizer.normalizeOrNull(it) + }.toSet(), localCache = localCache, ) } // Try to load saved account on startup DisposableEffect(Unit) { - scope.launch(Dispatchers.IO) { - // Load account on IO dispatcher to avoid blocking UI with password prompt (readLine) - accountManager.loadSavedAccount() - } - relayManager.addDefaultRelays() relayManager.connect() - - // Start subscriptions coordinator subscriptionsCoordinator.start() + scope.launch(Dispatchers.IO) { + if (accountManager.hasBunkerAccount()) { + // Show connecting UI while dedicated NIP-46 client connects + accountManager.setConnectingRelays() + } + val result = accountManager.loadSavedAccount() + if (result.isSuccess) { + val current = accountManager.currentAccount() + if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) { + accountManager.startHeartbeat(scope) + } + } else if (accountManager.hasBunkerAccount()) { + // Corrupt bunker state — fall back to login screen + accountManager.logout(deleteKey = true) + } + } + onDispose { + accountManager.stopHeartbeat() + runBlocking { accountManager.disconnectNip46Client() } subscriptionsCoordinator.clear() relayManager.disconnect() + scope.cancel() } } @@ -426,7 +463,21 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { }, + onLoginSuccess = { + // Start heartbeat if bunker account + val current = accountManager.currentAccount() + if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) { + accountManager.startHeartbeat(scope) + } + }, + ) + } + + is AccountState.ConnectingRelays -> { + val relays by relayManager.relayStatuses.collectAsState() + ConnectingRelaysScreen( + subtitle = "Restoring remote signer session", + relayStatuses = relays, ) } @@ -476,6 +527,15 @@ fun App( } } } + + // Force logout dialog overlay + val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() + forceLogoutReason?.let { reason -> + ForceLogoutDialog( + reason = reason, + onDismiss = { accountManager.clearForceLogoutReason() }, + ) + } } } } @@ -497,6 +557,8 @@ fun MainContent( ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val signerConnectionState by accountManager.signerConnectionState.collectAsState() + val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState() // DM infrastructure — hoisted here so it survives screen navigation val dmSendTracker = @@ -618,6 +680,8 @@ fun MainContent( onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, modifier = Modifier.weight(1f), ) } @@ -632,6 +696,8 @@ fun MainContent( deckState.addColumn(DeckColumnType.Settings) } }, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, ) VerticalDivider() @@ -686,7 +752,7 @@ fun ProfileScreen( Spacer(Modifier.height(24.dp)) OutlinedButton( - onClick = { scope.launch { accountManager.logout() } }, + onClick = { scope.launch { accountManager.logout(deleteKey = true) } }, colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( contentColor = Color.Red, @@ -889,5 +955,18 @@ fun RelaySettingsScreen( Text("Reset to Defaults") } } + + Spacer(Modifier.height(16.dp)) + + val logoutScope = rememberCoroutineScope() + OutlinedButton( + onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Logout") + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index d27637e3d..7fab08fbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -21,50 +21,92 @@ package com.vitorpamplona.amethyst.desktop.account import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.domain.nip46.BunkerLoginUseCase +import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +sealed class SignerType { + data object Internal : SignerType() + + data class Remote( + val bunkerUri: String, + ) : SignerType() +} sealed class AccountState { data object LoggedOut : AccountState() + data object ConnectingRelays : AccountState() + data class LoggedIn( val signer: NostrSigner, val pubKeyHex: String, val npub: String, val nsec: String?, val isReadOnly: Boolean, + val signerType: SignerType = SignerType.Internal, ) : AccountState() } @Stable -class AccountManager private constructor( +class AccountManager internal constructor( private val secureStorage: SecureKeyStorage, + private val homeDir: File = File(System.getProperty("user.home")), ) { companion object { - /** - * Creates an AccountManager instance. - * - * @param context Platform-specific context (required on Android, ignored on Desktop) - * @return AccountManager instance - */ fun create(context: Any? = null): AccountManager { val storage = SecureKeyStorage.create(context) return AccountManager(storage) } + + internal const val HEARTBEAT_INTERVAL_MS = 60_000L + internal const val MAX_CONSECUTIVE_FAILURES = 3 + internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" + internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L + internal val NIP46_RELAYS = listOf("wss://relay.nsec.app") + } + + private val amethystDir: File by lazy { + File(homeDir, ".amethyst") } private val _accountState = MutableStateFlow(AccountState.LoggedOut) @@ -73,43 +115,334 @@ class AccountManager private constructor( private val _nwcConnection = MutableStateFlow(null) val nwcConnection: StateFlow = _nwcConnection.asStateFlow() + private val _signerConnectionState = MutableStateFlow(SignerConnectionState.NotRemote) + val signerConnectionState: StateFlow = _signerConnectionState.asStateFlow() + + private val _lastPingTimeSec = MutableStateFlow(null) + val lastPingTimeSec: StateFlow = _lastPingTimeSec.asStateFlow() + + private val _forceLogoutReason = MutableStateFlow(null) + val forceLogoutReason: StateFlow = _forceLogoutReason.asStateFlow() + + private val _loginProgress = MutableStateFlow(null) + val loginProgress: StateFlow = _loginProgress.asStateFlow() + + private var heartbeatJob: Job? = null + + // --- Dedicated NIP-46 client (isolated from general relay pool) --- + + private val nip46ClientMutex = Mutex() + private var nip46Client: NostrClient? = null + + private suspend fun getOrCreateNip46Client(): NostrClient = + nip46ClientMutex.withLock { + nip46Client ?: NostrClient( + BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), + ).also { + nip46Client = it + it.connect() + } + } + + suspend fun disconnectNip46Client() = + nip46ClientMutex.withLock { + nip46Client?.disconnect() + nip46Client = null + } + /** - * Loads the last saved account from secure storage. - * Call on app startup. + * Waits for the NIP-46 client to connect to at least one of the target relays. + * openSubscription() triggers async relay connection via sendOrConnectAndSync, + * but we must wait for the websocket to be ready before sending requests. */ - suspend fun loadSavedAccount(): Result { - return try { - // For simplicity, we'll store the last logged-in npub in a simple file - // and use SecureKeyStorage to retrieve the private key - val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account")) - - val privKeyHex = - secureStorage.getPrivateKey(lastNpub) - ?: return Result.failure(Exception("Private key not found for $lastNpub")) - - val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) - val signer = NostrSignerInternal(keyPair) - - val state = - AccountState.LoggedIn( - signer = signer, - pubKeyHex = keyPair.pubKey.toHexKey(), - npub = keyPair.pubKey.toNpub(), - nsec = keyPair.privKey?.toNsec(), - isReadOnly = false, - ) - _accountState.value = state - Result.success(state) - } catch (e: Exception) { - Result.failure(e) + private suspend fun awaitNip46RelayConnection( + client: NostrClient, + targetRelays: Set, + ) { + withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) { + client.connectedRelaysFlow().first { connected -> + targetRelays.any { it in connected } + } } } - /** - * Saves the current account to secure storage. - */ + private fun updateRelayLoginStatus( + relay: NormalizedRelayUrl, + status: RelayLoginStatus, + ) { + val current = _loginProgress.value ?: return + val updated = current.relayStatuses + (relay to status) + _loginProgress.value = + when (current) { + is LoginProgress.ConnectingToRelays -> current.copy(relayStatuses = updated) + is LoginProgress.WaitingForSigner -> current.copy(relayStatuses = updated) + is LoginProgress.SendingAck -> current.copy(relayStatuses = updated) + } + } + + private fun createLoginRelayListener(): IRelayClientListener = + object : IRelayClientListener { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + updateRelayLoginStatus(relay.url, RelayLoginStatus.CONNECTED) + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + updateRelayLoginStatus(relay.url, RelayLoginStatus.FAILED) + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is EventCmd) { + updateRelayLoginStatus( + relay.url, + if (success) RelayLoginStatus.EVENT_SENT else RelayLoginStatus.SEND_FAILED, + ) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + } + } + + // --- Account loading --- + + suspend fun loadSavedAccount(): Result = + try { + val lastNpub = getLastNpub() + val bunkerUri = getBunkerUri() + + if (bunkerUri != null) { + loadBunkerAccount(bunkerUri, lastNpub) + } else if (lastNpub != null) { + loadInternalAccount(lastNpub) + } else { + Result.failure(Exception("No saved account")) + } + } catch (e: Exception) { + Result.failure(e) + } + + private suspend fun loadInternalAccount(npub: String): Result { + val privKeyHex = + secureStorage.getPrivateKey(npub) + ?: return Result.failure(Exception("Private key not found for $npub")) + + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + return Result.success(state) + } + + private suspend fun loadBunkerAccount( + bunkerUri: String, + npub: String?, + ): Result { + val ephemeralPrivKeyHex = + secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) + ?: return Result.failure(Exception("Ephemeral key not found")) + + val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray()) + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + + val nip46Client = getOrCreateNip46Client() + val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client) + remoteSigner.openSubscription() + + val pubKeyHex = + if (npub != null) { + decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub")) + } else { + // npub missing (e.g. last_account.txt deleted) — must wait for relay + // before calling getPublicKey() to recover from signer + awaitNip46RelayConnection(nip46Client, remoteSigner.relays) + remoteSigner.getPublicKey() + } + + val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub() + if (npub == null) saveLastNpub(resolvedNpub) + + val state = + AccountState.LoggedIn( + signer = remoteSigner, + pubKeyHex = pubKeyHex, + npub = resolvedNpub, + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote(bunkerUri), + ) + _accountState.value = state + _signerConnectionState.value = SignerConnectionState.Connected + return Result.success(state) + } + + // --- Bunker login --- + + suspend fun loginWithBunker(bunkerUri: String): Result { + val listener = createLoginRelayListener() + var client: NostrClient? = null + try { + val ephemeralKeyPair = KeyPair() + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + + val nip46Client = getOrCreateNip46Client() + client = nip46Client + + val relaysFromUri = parseBunkerRelays(bunkerUri) + _loginProgress.value = + LoginProgress.ConnectingToRelays( + relaysFromUri.associateWith { RelayLoginStatus.CONNECTING }, + ) + nip46Client.subscribe(listener) + + _loginProgress.value = + LoginProgress.WaitingForSigner( + relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), + ) + + val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client) + + val state = + AccountState.LoggedIn( + signer = result.signer, + pubKeyHex = result.pubKeyHex, + npub = result.pubKeyHex.hexToByteArray().toNpub(), + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote(bunkerUri), + ) + _accountState.value = state + _signerConnectionState.value = SignerConnectionState.Connected + + saveBunkerAccount( + bunkerUri = stripBunkerSecret(bunkerUri), + ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(), + npub = state.npub, + ) + + return Result.success(state) + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + return Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection.")) + } catch (e: SignerExceptions.TimedOutException) { + return Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection.")) + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + return Result.failure(Exception("Connection rejected by remote signer.")) + } catch (e: SignerExceptions.CouldNotPerformException) { + return Result.failure(Exception("Remote signer error: ${e.message}")) + } catch (e: Exception) { + return Result.failure(Exception("Connection failed: ${e.message}")) + } finally { + _loginProgress.value = null + client?.unsubscribe(listener) + } + } + + // --- Nostrconnect login --- + + suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result { + val listener = createLoginRelayListener() + var client: NostrClient? = null + try { + val ephemeralKeyPair = KeyPair() + val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop") + + val nip46Client = getOrCreateNip46Client() + client = nip46Client + + _loginProgress.value = + LoginProgress.ConnectingToRelays( + uriData.relays.associateWith { RelayLoginStatus.CONNECTING }, + ) + nip46Client.subscribe(listener) + + onUriGenerated(uriData.uri) + + _loginProgress.value = + LoginProgress.WaitingForSigner( + relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), + ) + + val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client) + + val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" } + val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams" + + val state = + AccountState.LoggedIn( + signer = result.signer, + pubKeyHex = result.pubKeyHex, + npub = result.pubKeyHex.hexToByteArray().toNpub(), + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote(syntheticBunkerUri), + ) + _accountState.value = state + _signerConnectionState.value = SignerConnectionState.Connected + + saveBunkerAccount( + bunkerUri = syntheticBunkerUri, + ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(), + npub = state.npub, + ) + + return Result.success(state) + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + return Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code.")) + } catch (e: Exception) { + return Result.failure(Exception("Connection failed: ${e.message}")) + } finally { + _loginProgress.value = null + client?.unsubscribe(listener) + } + } + + private suspend fun saveBunkerAccount( + bunkerUri: String, + ephemeralPrivKeyHex: String, + npub: String, + ) { + saveLastNpub(npub) + secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex) + saveBunkerUri(bunkerUri) + } + + fun hasBunkerAccount(): Boolean = getBunkerFile().exists() + + fun setConnectingRelays() { + _accountState.value = AccountState.ConnectingRelays + } + + // --- Save/generate (existing) --- + suspend fun saveCurrentAccount(): Result { val current = currentAccount() ?: return Result.failure(Exception("No account logged in")) + + // Bunker accounts are saved during loginWithBunker + if (current.signerType is SignerType.Remote) return Result.success(Unit) + if (current.isReadOnly || current.nsec == null) { return Result.failure(Exception("Cannot save read-only account")) } @@ -146,7 +479,6 @@ class AccountManager private constructor( fun loginWithKey(keyInput: String): Result { val trimmedInput = keyInput.trim() - // Try as private key first (nsec or hex) val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput) if (privKeyHex != null) { return try { @@ -168,7 +500,6 @@ class AccountManager private constructor( } } - // Try as public key (npub or hex) - read-only mode val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput) if (pubKeyHex != null) { return try { @@ -190,27 +521,96 @@ class AccountManager private constructor( } } - return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format.")) + return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, hex, or bunker:// URI.")) } + // --- Logout --- + suspend fun logout(deleteKey: Boolean = false) { val current = currentAccount() - if (deleteKey && current != null) { - try { - secureStorage.deletePrivateKey(current.npub) - clearLastNpub() - } catch (e: SecureStorageException) { - // Log error but still logout + if (current != null) { + // Clean up remote signer if bunker account + if (current.signerType is SignerType.Remote) { + (current.signer as? NostrSignerRemote)?.closeSubscription() + if (deleteKey) { + try { + secureStorage.deletePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) + } catch (_: SecureStorageException) { + } + getBunkerFile().delete() + } + } + if (deleteKey) { + try { + secureStorage.deletePrivateKey(current.npub) + clearLastNpub() + } catch (_: SecureStorageException) { + } } } + disconnectNip46Client() + _signerConnectionState.value = SignerConnectionState.NotRemote + _lastPingTimeSec.value = null _accountState.value = AccountState.LoggedOut + // Cancel heartbeat LAST — may be called from within the heartbeat coroutine + stopHeartbeat() } + suspend fun forceLogoutWithReason(reason: String) { + _forceLogoutReason.value = reason + logout(deleteKey = false) + } + + fun clearForceLogoutReason() { + _forceLogoutReason.value = null + } + + // --- Heartbeat --- + + fun startHeartbeat(scope: CoroutineScope) { + heartbeatJob?.cancel() + heartbeatJob = + scope.launch { + var consecutiveFailures = 0 + while (isActive) { + delay(HEARTBEAT_INTERVAL_MS) + val current = currentAccount() ?: continue + val remoteSigner = current.signer as? NostrSignerRemote ?: continue + try { + remoteSigner.ping() + consecutiveFailures = 0 + _signerConnectionState.value = SignerConnectionState.Connected + _lastPingTimeSec.value = TimeUtils.now() + } catch (_: SignerExceptions.ManuallyUnauthorizedException) { + forceLogoutWithReason("Remote signer revoked access.") + return@launch + } catch (_: Exception) { + consecutiveFailures++ + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + forceLogoutWithReason( + "Lost connection to remote signer after $MAX_CONSECUTIVE_FAILURES failed pings.", + ) + return@launch + } + _signerConnectionState.value = SignerConnectionState.Disconnected + } + } + } + } + + fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + // --- Accessors --- + fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn - // NWC (Nostr Wallet Connect) methods + // --- NWC --- + fun hasNwcSetup(): Boolean = _nwcConnection.value != null fun setNwcConnection(uri: String): Result = @@ -233,42 +633,88 @@ class AccountManager private constructor( if (!uri.isNullOrEmpty()) { try { _nwcConnection.value = Nip47WalletConnect.parse(uri) - } catch (e: Exception) { - // Invalid stored URI, clear it + } catch (_: Exception) { getNwcFile().delete() } } } + // --- File storage helpers --- + + private fun ensureAmethystDir() { + if (!amethystDir.exists()) { + amethystDir.mkdirs() + } + try { + Files.setPosixFilePermissions( + amethystDir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows — file system ACLs handle this + } catch (_: Exception) { + } + } + private fun saveNwcUri(uri: String) { - val file = getNwcFile() - file.parentFile?.mkdirs() - file.writeText(uri) + ensureAmethystDir() + getNwcFile().writeText(uri) } - private fun getNwcFile(): java.io.File { - val homeDir = System.getProperty("user.home") - return java.io.File(homeDir, ".amethyst/nwc_connection.txt") - } + private fun getNwcFile(): File = File(amethystDir, "nwc_connection.txt") - // Simple file-based storage for last npub (non-sensitive data) private fun getLastNpub(): String? { val file = getPrefsFile() return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null } private fun saveLastNpub(npub: String) { - val file = getPrefsFile() - file.parentFile?.mkdirs() - file.writeText(npub) + ensureAmethystDir() + getPrefsFile().writeText(npub) } private fun clearLastNpub() { getPrefsFile().delete() } - private fun getPrefsFile(): File { - val homeDir = System.getProperty("user.home") - return File(homeDir, ".amethyst/last_account.txt") + private fun getPrefsFile(): File = File(amethystDir, "last_account.txt") + + private fun getBunkerUri(): String? { + val file = getBunkerFile() + return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null } + + private fun saveBunkerUri(uri: String) { + ensureAmethystDir() + getBunkerFile().writeText(uri) + } + + private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt") +} + +internal fun parseBunkerRelays(uri: String): Set { + val idx = uri.indexOf('?') + if (idx < 0) return emptySet() + return uri + .substring(idx + 1) + .split("&") + .filter { it.startsWith("relay=", ignoreCase = true) } + .map { NormalizedRelayUrl(it.removePrefix("relay=")) } + .toSet() +} + +internal fun stripBunkerSecret(uri: String): String { + val idx = uri.indexOf('?') + if (idx < 0) return uri + val base = uri.substring(0, idx) + val params = + uri + .substring(idx + 1) + .split("&") + .filter { !it.startsWith("secret=", ignoreCase = true) } + return if (params.isEmpty()) base else "$base?${params.joinToString("&")}" } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtils.kt new file mode 100644 index 000000000..c476d3c97 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtils.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.account + +private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$") + +fun validateBunkerUri(input: String): String? { + val trimmed = input.trim() + if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI" + + val afterScheme = trimmed.substring("bunker://".length) + val parts = afterScheme.split("?", limit = 2) + val pubkeyPart = parts[0] + + if (pubkeyPart.length != 64 || !pubkeyPart.matches(HEX_64_REGEX)) { + return "Invalid bunker URI. Expected: bunker://<64-hex-chars>?relay=wss://..." + } + + if (parts.size < 2 || !parts[1].contains("relay=wss://", ignoreCase = true)) { + return "Bunker URI must include at least one relay parameter (relay=wss://...)" + } + + return null // valid +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/LoginProgress.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/LoginProgress.kt new file mode 100644 index 000000000..1ce722afa --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/LoginProgress.kt @@ -0,0 +1,47 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +enum class RelayLoginStatus { + CONNECTING, + CONNECTED, + EVENT_SENT, + SEND_FAILED, + FAILED, +} + +sealed class LoginProgress { + abstract val relayStatuses: Map + + data class ConnectingToRelays( + override val relayStatuses: Map = emptyMap(), + ) : LoginProgress() + + data class WaitingForSigner( + override val relayStatuses: Map = emptyMap(), + ) : LoginProgress() + + data class SendingAck( + override val relayStatuses: Map = emptyMap(), + ) : LoginProgress() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index ba278453f..c51526c62 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableSharedFlow @@ -85,15 +86,27 @@ class DesktopLocalCache : ICacheProvider { if (user != null) return listOf(user) } + val dualCase = + listOf( + DualCase(prefix.lowercase(), prefix.uppercase()), + ) + // Search by name/displayName/nip05/lud16 return users.values .filter { user -> - user.metadataOrNull()?.anyNameStartsWith(prefix) == true || - user.pubkeyHex.startsWith(prefix, ignoreCase = true) || - user.pubkeyNpub().startsWith(prefix, ignoreCase = true) + val metadata = user.metadataOrNull() + if (metadata == null) { + user.pubkeyHex.startsWith(prefix, true) || + user.pubkeyNpub().startsWith(prefix, true) + } else { + metadata.anyNameOrAddressContains(dualCase) || + user.pubkeyHex.startsWith(prefix, true) || + user.pubkeyNpub().startsWith(prefix, true) + } }.sortedWith( compareBy( - { !it.toBestDisplayName().startsWith(prefix, ignoreCase = true) }, + { it.metadataOrNull()?.anyNameStartsWith(dualCase) == false }, + { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false }, { it.toBestDisplayName().lowercase() }, { it.pubkeyHex }, ), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 432fd7c47..daf10b553 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -99,7 +99,7 @@ fun ChessScreen( remember(account.pubKeyHex) { DesktopChessViewModelNew(account, relayManager, scope) } - val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() val broadcastStatus by viewModel.broadcastStatus.collectAsState() val activeGames by viewModel.activeGames.collectAsState() // Observe state version to force recomposition when game state changes @@ -147,11 +147,10 @@ fun ChessScreen( // Subscribe to user metadata for pubkeys that need it val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState() - rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) { + rememberSubscription(connectedRelays, pubkeysNeeded, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) { createMetadataListSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeys = pubkeysNeeded.toList(), onEvent = { event, _, _, _ -> viewModel.handleIncomingEvent(event) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt index 8a42cad81..1f98c52ef 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt @@ -221,6 +221,6 @@ open class RelayConnectionManager( cmd: Command, success: Boolean, ) { - // Command send tracking + // Command send tracking — no-op for now } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt index 32f69dac9..e31000e7e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt @@ -46,5 +46,6 @@ object DefaultRelays { "wss://relay.snort.social", "wss://nostr.wine", "wss://relay.noswhere.com", + "wss://relay.primal.net", ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt index ab0238304..f7ce8727d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt @@ -110,7 +110,9 @@ fun rememberSubscription( } onDispose { - subscription?.let { relayManager.unsubscribe(it.subId) } + subscription?.let { + relayManager.unsubscribe(it.subId) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index d8e915825..c4f1570c6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -76,7 +76,7 @@ fun BookmarksScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { - val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() val scope = rememberCoroutineScope() // Tab state @@ -121,9 +121,8 @@ fun BookmarksScreen( } // Subscribe to user's bookmark list (kind 30001) - rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { SubscriptionConfig( subId = "bookmarks-list-${account.pubKeyHex.take(8)}", filters = @@ -134,7 +133,7 @@ fun BookmarksScreen( limit = 1, ), ), - relays = configuredRelays, + relays = connectedRelays, onEvent = { event, _, _, _ -> if (event is BookmarkListEvent) { bookmarkList = event @@ -179,9 +178,8 @@ fun BookmarksScreen( } // Subscribe to fetch the actual public bookmarked events - rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) { + rememberSubscription(connectedRelays, publicBookmarkIds, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) { publicEventState.clear() SubscriptionConfig( subId = "public-bookmarked-events-${System.currentTimeMillis()}", @@ -189,7 +187,7 @@ fun BookmarksScreen( listOf( FilterBuilders.byIds(publicBookmarkIds), ), - relays = configuredRelays, + relays = connectedRelays, onEvent = { event, _, _, _ -> publicEventState.addItem(event) }, @@ -201,9 +199,8 @@ fun BookmarksScreen( } // Subscribe to fetch the actual private bookmarked events - rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) { + rememberSubscription(connectedRelays, privateBookmarkIds, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) { privateEventState.clear() SubscriptionConfig( subId = "private-bookmarked-events-${System.currentTimeMillis()}", @@ -211,7 +208,7 @@ fun BookmarksScreen( listOf( FilterBuilders.byIds(privateBookmarkIds), ), - relays = configuredRelays, + relays = connectedRelays, onEvent = { event, _, _, _ -> privateEventState.addItem(event) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 9c6a805a8..dcf903fa7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -177,15 +177,12 @@ private suspend fun publishNote( replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { withContext(Dispatchers.IO) { - // Check read-only mode if (account.isReadOnly) { throw IllegalStateException("Cannot post in read-only mode") } - // Use shared PublishAction from commons val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo) - // Broadcast to all configured relays relayManager.broadcastToAll(signedEvent) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index e79aa3cfa..e539b1510 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -83,6 +83,8 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map /** * Note card with action buttons. @@ -158,7 +160,14 @@ fun FeedScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - val relayStatuses by relayManager.relayStatuses.collectAsState() + // Configured relay URLs only — stabilized with distinctUntilChanged() to prevent + // subscription churn from relay status changes (pings, connect/disconnect). + // openReqSubscription connects relays on demand; no need to wait for connectedRelays. + val configuredRelays by remember { + relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() + }.collectAsState(emptySet()) val scope = rememberCoroutineScope() val eventState = remember { @@ -190,15 +199,15 @@ fun FeedScreen( val initialLoadComplete = eoseReceivedCount > 0 // Load followed users for Following feed mode - rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) { if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { createContactListSubscription( relays = configuredRelays, pubKeyHex = account.pubKeyHex, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> if (event is ContactListEvent) { - followedUsers = event.verifiedFollowKeySet() + val follows = event.verifiedFollowKeySet() + followedUsers = follows } }, ) @@ -208,8 +217,7 @@ fun FeedScreen( } // Load user's bookmark list - rememberSubscription(relayStatuses, account, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, account, relayManager = relayManager) { if (configuredRelays.isNotEmpty() && account != null) { SubscriptionConfig( subId = "bookmarks-${account.pubKeyHex.take(8)}", @@ -249,8 +257,7 @@ fun FeedScreen( } // Subscribe to feed based on mode - rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { if (configuredRelays.isEmpty()) { return@rememberSubscription null } @@ -297,8 +304,7 @@ fun FeedScreen( // Subscribe to zaps for visible events val eventIds = events.map { it.id } - rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } @@ -328,8 +334,7 @@ fun FeedScreen( .flatten() .map { it.senderPubKey } .distinct() - rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) { if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { return@rememberSubscription null } @@ -359,8 +364,7 @@ fun FeedScreen( } // Subscribe to reactions for visible events - rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } @@ -382,8 +386,7 @@ fun FeedScreen( } // Subscribe to replies for visible events - rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } @@ -410,8 +413,7 @@ fun FeedScreen( } // Subscribe to reposts for visible events - rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } @@ -443,13 +445,12 @@ fun FeedScreen( } // Fallback subscription if coordinator not available - rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { + rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { // Skip if using coordinator if (subscriptionsCoordinator != null) { return@rememberSubscription null } - val configuredRelays = relayStatuses.keys if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) { return@rememberSubscription null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 845702443..52b5dbee0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -20,15 +20,28 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,16 +49,19 @@ import androidx.compose.runtime.rememberCoroutineScope 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.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop import com.vitorpamplona.amethyst.commons.resources.login_title import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.RelayStatus import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource @Composable @@ -56,6 +72,7 @@ fun LoginScreen( var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val loginProgress by accountManager.loginProgress.collectAsState() Column( modifier = Modifier.fillMaxSize().padding(32.dp), @@ -81,9 +98,8 @@ fun LoginScreen( LoginCard( onLogin = { keyInput -> accountManager.loginWithKey(keyInput).map { - // Save account to secure storage (use IO dispatcher to avoid blocking UI) - scope.launch(Dispatchers.IO) { - accountManager.saveCurrentAccount() + scope.launch { + withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } onLoginSuccess() } } @@ -92,18 +108,29 @@ fun LoginScreen( generatedAccount = accountManager.generateNewAccount() showNewKeyDialog = true }, + onLoginBunker = { bunkerUri -> + accountManager.loginWithBunker(bunkerUri).map { + onLoginSuccess() + } + }, + onLoginNostrConnect = { onUriGenerated -> + accountManager.loginWithNostrConnect(onUriGenerated).map { + onLoginSuccess() + } + }, + loginProgress = loginProgress, ) - if (showNewKeyDialog && generatedAccount != null) { + val account = generatedAccount + if (showNewKeyDialog && account != null) { Spacer(Modifier.height(24.dp)) NewKeyWarningCard( - npub = generatedAccount!!.npub, - nsec = generatedAccount!!.nsec, + npub = account.npub, + nsec = account.nsec, onContinue = { showNewKeyDialog = false - // Save generated account (use IO dispatcher to avoid blocking UI) - scope.launch(Dispatchers.IO) { - accountManager.saveCurrentAccount() + scope.launch { + withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } onLoginSuccess() } }, @@ -111,3 +138,126 @@ fun LoginScreen( } } } + +@Composable +fun ConnectingRelaysScreen( + subtitle: String = "Restoring session", + relayStatuses: Map = emptyMap(), +) { + val total = relayStatuses.size + val connected = relayStatuses.values.count { it.connected } + val failed = relayStatuses.values.count { it.error != null } + val progress = if (total > 0) connected.toFloat() / total else 0f + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + "Amethyst", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(24.dp)) + + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + + Spacer(Modifier.height(16.dp)) + + Text( + if (total > 0) "Connecting to relays ($connected/$total)" else "Connecting to relays...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + + // Progress bar + if (total > 0) { + Spacer(Modifier.height(16.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "relay-progress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.widthIn(max = 300.dp).fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + // Per-relay status rows + Column( + modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + relayStatuses.values.forEach { status -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + when { + status.connected -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(14.dp), + ) + } + + status.error != null -> { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(14.dp), + ) + } + + else -> { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 1.5.dp, + ) + } + } + Text( + status.url.url + .removePrefix("wss://") + .removeSuffix("/"), + style = MaterialTheme.typography.bodySmall, + color = + if (status.error != null) { + MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + if (status.connected && status.pingMs != null) { + Text( + "${status.pingMs}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 0fc6ba44a..e1be49646 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -113,7 +113,6 @@ fun NotificationsScreen( subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - val relayStatuses by relayManager.relayStatuses.collectAsState() val scope = rememberCoroutineScope() val notificationState = remember { @@ -139,11 +138,10 @@ fun NotificationsScreen( val initialLoadComplete = eoseReceivedCount > 0 // Subscribe to notifications - rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { createNotificationsSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeyHex = account.pubKeyHex, onEvent = { event, _, _, _ -> // Skip events from the user themselves (except zaps) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 11121055b..3853e4b6e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -175,7 +175,6 @@ fun ReadsScreen( onNavigateToArticle: (String) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - val relayStatuses by relayManager.relayStatuses.collectAsState() val scope = rememberCoroutineScope() val eventState = @@ -195,11 +194,11 @@ fun ReadsScreen( val initialLoadComplete = eoseReceivedCount > 0 // Load followed users for Following feed mode - rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { + rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) { + val connectedRelays = connectedRelays + if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { createContactListSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeyHex = account.pubKeyHex, onEvent = { event, _, _, _ -> if (event is ContactListEvent) { @@ -219,16 +218,16 @@ fun ReadsScreen( } // Subscribe to long-form content feed - rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty()) { + rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) { + val connectedRelays = connectedRelays + if (connectedRelays.isEmpty()) { return@rememberSubscription null } when (feedMode) { FeedMode.GLOBAL -> { createLongFormFeedSubscription( - relays = configuredRelays, + relays = connectedRelays, onEvent = { event, _, _, _ -> if (event is LongTextNoteEvent) { eventState.addItem(event) @@ -243,7 +242,7 @@ fun ReadsScreen( FeedMode.FOLLOWING -> { if (followedUsers.isNotEmpty()) { createFollowingLongFormFeedSubscription( - relays = configuredRelays, + relays = connectedRelays, followedUsers = followedUsers.toList(), onEvent = { event, _, _, _ -> if (event is LongTextNoteEvent) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index d72b914ef..ca79ca553 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -96,7 +96,6 @@ fun ThreadScreen( onReply: (Event) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - val relayStatuses by relayManager.relayStatuses.collectAsState() val scope = rememberCoroutineScope() // State for the root note @@ -149,9 +148,8 @@ fun ThreadScreen( } // Subscribe to user's bookmark list - rememberSubscription(relayStatuses, account, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty() && account != null) { + rememberSubscription(connectedRelays, account, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && account != null) { SubscriptionConfig( subId = "thread-bookmarks-${account.pubKeyHex.take(8)}", filters = @@ -162,7 +160,7 @@ fun ThreadScreen( limit = 1, ), ), - relays = configuredRelays, + relays = connectedRelays, onEvent = { event, _, _, _ -> if (event is BookmarkListEvent) { bookmarkList = event @@ -183,11 +181,10 @@ fun ThreadScreen( } // Subscribe to the root note - rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, noteId, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { createNoteSubscription( - relays = configuredRelays, + relays = connectedRelays, noteId = noteId, onEvent = { event, _, _, _ -> if (event.id == noteId) { @@ -205,11 +202,10 @@ fun ThreadScreen( } // Subscribe to replies - rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, noteId, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { createThreadRepliesSubscription( - relays = configuredRelays, + relays = connectedRelays, noteId = noteId, onEvent = { event, _, _, _ -> replyEventState.addItem(event) @@ -225,14 +221,13 @@ fun ThreadScreen( // Subscribe to zaps for thread events val allEventIds = listOf(noteId) + replyEvents.map { it.id } - rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { + if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { return@rememberSubscription null } createZapsSubscription( - relays = configuredRelays, + relays = connectedRelays, eventIds = allEventIds, onEvent = { event, _, _, _ -> if (event is LnZapEvent) { @@ -251,14 +246,13 @@ fun ThreadScreen( } // Subscribe to reactions for thread events - rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { + if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { return@rememberSubscription null } createReactionsSubscription( - relays = configuredRelays, + relays = connectedRelays, eventIds = allEventIds, onEvent = { event, _, _, _ -> if (event is ReactionEvent) { @@ -274,14 +268,13 @@ fun ThreadScreen( } // Subscribe to replies for thread events (for counts) - rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { + if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { return@rememberSubscription null } createRepliesSubscription( - relays = configuredRelays, + relays = connectedRelays, eventIds = allEventIds, onEvent = { event, _, _, _ -> val replyToId = @@ -301,14 +294,13 @@ fun ThreadScreen( } // Subscribe to reposts for thread events - rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty() || allEventIds.isEmpty()) { + rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) { + if (connectedRelays.isEmpty() || allEventIds.isEmpty()) { return@rememberSubscription null } createRepostsSubscription( - relays = configuredRelays, + relays = connectedRelays, eventIds = allEventIds, onEvent = { event, _, _, _ -> if (event is RepostEvent) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 704a8f03a..eed9c760f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -109,7 +109,6 @@ fun UserProfileScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() - val relayStatuses by relayManager.relayStatuses.collectAsState() // User metadata var displayName by remember { mutableStateOf(null) } @@ -154,11 +153,10 @@ fun UserProfileScreen( var eoseReceivedCount by remember(account) { mutableStateOf(0) } // Load current user's contact list (for follow state) - rememberSubscription(relayStatuses, account, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty() && account != null) { + rememberSubscription(connectedRelays, account, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && account != null) { createContactListSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeyHex = account.pubKeyHex, onEvent = { event, _, _, _ -> if (event is ContactListEvent) { @@ -175,7 +173,7 @@ fun UserProfileScreen( eoseReceivedCount++ // Wait for EOSE from at least 2 relays or all relays before enabling button - val minEoseCount = minOf(2, configuredRelays.size) + val minEoseCount = minOf(2, connectedRelays.size) if (eoseReceivedCount >= minEoseCount && !contactListLoaded) { contactListLoaded = true } @@ -194,11 +192,10 @@ fun UserProfileScreen( } // Subscribe to user metadata - rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { createMetadataSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> if (event is MetadataEvent) { @@ -229,11 +226,10 @@ fun UserProfileScreen( } // Subscribe to profile user's contact list (for following count) - rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { createContactListSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> if (event is ContactListEvent) { @@ -252,9 +248,8 @@ fun UserProfileScreen( val followerAuthors = remember(pubKeyHex) { mutableSetOf() } // Subscribe to followers (contact lists that tag this user) - rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { // Clear previous followers when subscription restarts followerAuthors.clear() followersCount = 0 @@ -269,7 +264,7 @@ fun UserProfileScreen( limit = 500, ), ), - relays = configuredRelays, + relays = connectedRelays, onEvent = { event, _, _, _ -> // Count unique authors who follow this user if (followerAuthors.add(event.pubKey)) { @@ -284,13 +279,12 @@ fun UserProfileScreen( } // Subscribe to user posts - rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isNotEmpty()) { + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { postsLoading = true postsError = null createUserPostsSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> eventState.addItem(event) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/ForceLogoutDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/ForceLogoutDialog.kt new file mode 100644 index 000000000..30bc5ee94 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/ForceLogoutDialog.kt @@ -0,0 +1,54 @@ +/* + * 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.desktop.ui.auth + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun ForceLogoutDialog( + reason: String, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + "Session Terminated", + style = MaterialTheme.typography.titleMedium, + ) + }, + text = { + Text( + reason, + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("OK") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index f643b99b6..67d1bcdf7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -28,20 +28,32 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res @@ -49,29 +61,27 @@ import com.vitorpamplona.amethyst.commons.resources.login_button import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle import com.vitorpamplona.amethyst.commons.resources.login_card_title import com.vitorpamplona.amethyst.commons.resources.login_generate_button +import com.vitorpamplona.amethyst.desktop.account.LoginProgress +import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource -/** - * Login card with Nostr key input field and action buttons. - * - * @param onLogin Callback when login is attempted with the key input - * @param onGenerateNew Callback when "Generate New" is clicked - * @param modifier Modifier for the card - * @param cardWidth Width of the card (default 400.dp) - * @param title Card title - * @param subtitle Subtitle/hint text - */ @Composable fun LoginCard( onLogin: (String) -> Result, onGenerateNew: () -> Unit, + onLoginBunker: (suspend (String) -> Result)? = null, + onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result)? = null, + loginProgress: LoginProgress? = null, modifier: Modifier = Modifier, cardWidth: Dp = 400.dp, title: String = stringResource(Res.string.login_card_title), subtitle: String = stringResource(Res.string.login_card_subtitle), ) { - var keyInput by remember { mutableStateOf("") } - var errorMessage by remember { mutableStateOf(null) } + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = listOf("Paste Key", "Connect") Card( modifier = modifier.width(cardWidth), @@ -92,48 +102,266 @@ fun LoginCard( Spacer(Modifier.height(16.dp)) - KeyInputField( - value = keyInput, - onValueChange = { - keyInput = it - errorMessage = null - }, - errorMessage = errorMessage, - ) + if (onLoginNostrConnect != null) { + @Suppress("DEPRECATION") + PrimaryTabRow( + selectedTabIndex = selectedTab, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)), + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) }, + ) + } + } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) + } - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + when (selectedTab) { + 0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle, loginProgress) + 1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect, loginProgress) + } + } + } +} - Spacer(Modifier.height(16.dp)) +@Composable +private fun PasteKeyContent( + onLogin: (String) -> Result, + onGenerateNew: () -> Unit, + onLoginBunker: (suspend (String) -> Result)?, + subtitle: String, + loginProgress: LoginProgress? = null, +) { + var keyInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + var isConnecting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true) + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) + + Spacer(Modifier.height(8.dp)) + + if (isBunker) { + Text( + "This URI connects to your remote signer. Treat it like a password.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + if (isConnecting) { + if (loginProgress != null) { + LoginProgressSteps(loginProgress) + } else { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { - Button( - onClick = { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Connecting to remote signer...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + if (isBunker && onLoginBunker != null) { + val validationError = validateBunkerUri(keyInput) + if (validationError != null) { + errorMessage = validationError + return@Button + } + isConnecting = true + errorMessage = null + scope.launch(Dispatchers.IO) { + val result = onLoginBunker(keyInput.trim()) + withContext(Dispatchers.Main) { + result.fold( + onSuccess = { isConnecting = false }, + onFailure = { + errorMessage = it.message + isConnecting = false + }, + ) + } + } + } else { onLogin(keyInput).fold( onSuccess = { /* handled by caller */ }, onFailure = { errorMessage = it.message }, ) - }, - modifier = Modifier.weight(1f), - enabled = keyInput.isNotBlank(), - ) { - Text(stringResource(Res.string.login_button)) - } + } + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button)) + } - OutlinedButton( - onClick = onGenerateNew, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(Res.string.login_generate_button)) + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.login_generate_button)) + } + } + } +} + +@Composable +private fun NostrConnectContent( + onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result, + loginProgress: LoginProgress? = null, +) { + var nostrConnectUri by remember { mutableStateOf(null) } + var isConnecting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + @Suppress("DEPRECATION") + val clipboardManager = LocalClipboardManager.current + + if (errorMessage != null) { + Text( + errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(12.dp)) + Button(onClick = { + errorMessage = null + nostrConnectUri = null + }) { + Text("Try Again") + } + } else if (!isConnecting) { + Text( + "Show a QR code for your signer app (e.g. Amber) to scan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + isConnecting = true + errorMessage = null + scope.launch(Dispatchers.IO) { + val result = + onLoginNostrConnect { uri -> + scope.launch(Dispatchers.Main) { nostrConnectUri = uri } + } + withContext(Dispatchers.Main) { + result.onFailure { + errorMessage = it.message + isConnecting = false + nostrConnectUri = null + } + } } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Start Connection") + } + } else { + val uri = nostrConnectUri + if (uri != null) { + QrCodeCanvas( + data = uri, + size = 200.dp, + ) + + Spacer(Modifier.height(12.dp)) + + Text( + uri, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + onClick = { clipboardManager.setText(AnnotatedString(uri)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Copy URI") + } + + Spacer(Modifier.height(12.dp)) + + if (loginProgress != null) { + LoginProgressSteps(loginProgress) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Waiting for signer to connect...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Generating connection...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt new file mode 100644 index 000000000..08655a5c4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt @@ -0,0 +1,220 @@ +/* + * 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.desktop.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.account.LoginProgress +import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus + +private enum class StepState { DONE, ACTIVE, PENDING } + +private data class StepInfo( + val label: String, + val state: StepState, +) + +@Composable +fun LoginProgressSteps( + progress: LoginProgress, + modifier: Modifier = Modifier, +) { + val steps = buildStepList(progress) + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + steps.forEach { step -> + StepRow(step) + + // Show relay rows under the active step + if (step.state == StepState.ACTIVE && progress.relayStatuses.isNotEmpty()) { + progress.relayStatuses.forEach { (relay, status) -> + RelayRow(relay.url, status) + } + } + } + } +} + +private fun buildStepList(progress: LoginProgress): List { + val orderedSteps = + listOf( + "Connecting to relays", + "Waiting for signer", + "Sending acknowledgment", + ) + + val activeIndex = + when (progress) { + is LoginProgress.ConnectingToRelays -> 0 + is LoginProgress.WaitingForSigner -> 1 + is LoginProgress.SendingAck -> 2 + } + + return orderedSteps.mapIndexed { index, label -> + StepInfo( + label = label, + state = + when { + index < activeIndex -> StepState.DONE + index == activeIndex -> StepState.ACTIVE + else -> StepState.PENDING + }, + ) + } +} + +@Composable +private fun StepRow(step: StepInfo) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + when (step.state) { + StepState.DONE -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(16.dp), + ) + } + + StepState.ACTIVE -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + + StepState.PENDING -> { + Spacer(Modifier.size(16.dp)) + } + } + Spacer(Modifier.width(8.dp)) + Text( + step.label, + style = MaterialTheme.typography.bodySmall, + color = + when (step.state) { + StepState.DONE -> Color(0xFF4CAF50) + StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface + StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + ) + } +} + +@Composable +private fun RelayRow( + url: String, + status: RelayLoginStatus, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 24.dp), + ) { + when (status) { + RelayLoginStatus.EVENT_SENT -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF2196F3), + modifier = Modifier.size(12.dp), + ) + } + + RelayLoginStatus.CONNECTED -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(12.dp), + ) + } + + RelayLoginStatus.FAILED, + RelayLoginStatus.SEND_FAILED, + -> { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(12.dp), + ) + } + + RelayLoginStatus.CONNECTING -> { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + ) + } + } + Spacer(Modifier.width(6.dp)) + val label = url.removePrefix("wss://").removeSuffix("/") + val statusLabel = + when (status) { + RelayLoginStatus.EVENT_SENT -> "$label (sent)" + RelayLoginStatus.SEND_FAILED -> "$label (send failed)" + RelayLoginStatus.FAILED -> "$label (failed)" + else -> label + } + Text( + statusLabel, + style = MaterialTheme.typography.labelSmall, + color = + when (status) { + RelayLoginStatus.FAILED, RelayLoginStatus.SEND_FAILED -> { + MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + } + + RelayLoginStatus.EVENT_SENT -> { + Color(0xFF2196F3) + } + + else -> { + MaterialTheme.colorScheme.onSurfaceVariant + } + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt new file mode 100644 index 000000000..cb8ea6442 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt @@ -0,0 +1,86 @@ +/* + * 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.desktop.ui.auth + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import java.awt.image.BufferedImage + +@Composable +fun QrCodeCanvas( + data: String, + modifier: Modifier = Modifier, + size: Dp = 200.dp, +) { + val bitmap = + remember(data) { + createQrBitmap(data) + } + + Canvas(modifier = modifier.size(size)) { + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = IntSize(bitmap.width, bitmap.height), + dstOffset = IntOffset.Zero, + dstSize = IntSize(this.size.width.toInt(), this.size.height.toInt()), + filterQuality = FilterQuality.None, + ) + } +} + +private fun createQrBitmap(data: String): ImageBitmap { + val matrix = + QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + 0, + 0, + mapOf(EncodeHintType.MARGIN to 1), + ) + + val w = matrix.width + val h = matrix.height + val black = 0xFF000000.toInt() + val white = 0xFFFFFFFF.toInt() + + val image = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + for (x in 0 until w) { + for (y in 0 until h) { + image.setRGB(x, y, if (matrix.get(x, y)) black else white) + } + } + + return image.toComposeImageBitmap() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index a01593013..dd220a06b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -80,18 +80,17 @@ fun NewDmDialog( val cachedUsers by searchState.cachedUserResults.collectAsState() val relaySearchResults by searchState.relaySearchResults.collectAsState() val isSearchingRelays by searchState.isSearchingRelays.collectAsState() - val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() val focusRequester = remember { FocusRequester() } // NIP-50 relay search when local cache has few/no results - rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty()) return@rememberSubscription null + rememberSubscription(connectedRelays, searchText, cachedUsers.size, relayManager = relayManager) { + if (connectedRelays.isEmpty()) return@rememberSubscription null if (searchState.shouldSearchRelays) { searchState.startRelaySearch() createSearchPeopleSubscription( - relays = configuredRelays, + relays = connectedRelays, searchQuery = searchText, limit = 20, onEvent = { event, _, _, _ -> @@ -113,16 +112,15 @@ fun NewDmDialog( } // Bech32 npub metadata loading - rememberSubscription(relayStatuses, searchText, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty() || searchText.length < 2) { + rememberSubscription(connectedRelays, searchText, relayManager = relayManager) { + if (connectedRelays.isEmpty() || searchText.length < 2) { return@rememberSubscription null } val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) if (pubkeyHex != null) { createMetadataSubscription( - relays = configuredRelays, + relays = connectedRelays, pubKeyHex = pubkeyHex, onEvent = { event, _, _, _ -> if (event is MetadataEvent) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 912042a1b..5ddc99b21 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -41,11 +41,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator @Composable fun DeckSidebar( onAddColumn: () -> Unit, onOpenSettings: () -> Unit, + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, modifier: Modifier = Modifier, ) { Column( @@ -78,6 +82,13 @@ fun DeckSidebar( Spacer(Modifier.weight(1f)) + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + ) + + Spacer(Modifier.size(8.dp)) + IconButton(onClick = onOpenSettings) { Icon( Icons.Default.Settings, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index e35e7c3fc..66e11d8d0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -54,6 +55,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -95,6 +98,8 @@ fun SinglePaneLayout( onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, modifier: Modifier = Modifier, ) { var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } @@ -131,6 +136,14 @@ fun SinglePaneLayout( }, ) } + + Spacer(Modifier.weight(1f)) + + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.padding(bottom = 12.dp), + ) } VerticalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 9467e1070..aca0f12d9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -44,7 +44,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.toTimeAgo @@ -71,8 +72,7 @@ fun NoteCard( onClick: (() -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null, ) { - val richTextParser = remember { RichTextParser() } - val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) } + val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } Card( modifier = modifier.fillMaxWidth(), @@ -154,11 +154,11 @@ fun NoteCard( @Composable fun RichTextContent( content: String, - urls: Set, + urls: Urls, modifier: Modifier = Modifier, maxLines: Int = 10, ) { - if (urls.isEmpty()) { + if (urls.withScheme.isEmpty()) { Text( text = content, style = MaterialTheme.typography.bodyMedium, @@ -171,7 +171,8 @@ fun RichTextContent( val annotatedText = buildAnnotatedString { var lastIndex = 0 - val sortedUrls = urls.sortedBy { content.indexOf(it) } + // TODO: User the other urls. + val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) } for (url in sortedUrls) { val startIndex = content.indexOf(url, lastIndex) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt new file mode 100644 index 000000000..2df73bf8e --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt @@ -0,0 +1,69 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import io.mockk.mockk +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AccountManagerBunkerLoginTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var amethystDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-bunker-test").toFile() + amethystDir = File(tempDir, ".amethyst") + amethystDir.mkdirs() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun hasBunkerAccountReturnsFalseWhenNoFile() { + assertFalse(manager.hasBunkerAccount()) + } + + @Test + fun hasBunkerAccountReturnsTrueWhenFileExists() { + File(amethystDir, "bunker_uri.txt").writeText("bunker://${"a".repeat(64)}?relay=wss://r.com") + assertTrue(manager.hasBunkerAccount()) + } + + @Test + fun setConnectingRelaysUpdatesState() { + manager.setConnectingRelays() + assertTrue(manager.accountState.value is AccountState.ConnectingRelays) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt new file mode 100644 index 000000000..7a97d52c8 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt @@ -0,0 +1,121 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountManagerHeartbeatTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + private lateinit var remoteSigner: NostrSignerRemote + + private val validHex = "a".repeat(64) + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-hb-test").toFile() + manager = AccountManager(storage, tempDir) + + // Create a real remote signer that we'll spy on + val ephemeral = NostrSignerInternal(KeyPair()) + remoteSigner = + spyk( + NostrSignerRemote.fromBunkerUri( + "bunker://$validHex?relay=wss://r.com", + ephemeral, + EmptyNostrClient, + ), + ) + } + + @AfterTest + fun teardown() { + manager.stopHeartbeat() + tempDir.deleteRecursively() + } + + private fun loginWithRemoteSigner() { + // Directly set the account state to a bunker-logged-in state + val keyPair = KeyPair() + val state = + AccountState.LoggedIn( + signer = remoteSigner, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote("bunker://$validHex?relay=wss://r.com"), + ) + // We need to access private _accountState — use loginWithKey then replace + // Actually, let's just use reflection or a simpler approach + // We'll test heartbeat indirectly by using the public API + } + + @Test + fun stopHeartbeatCancels() = + runTest { + // Just ensure stopHeartbeat doesn't crash when no heartbeat is running + manager.stopHeartbeat() + // And after starting + manager.startHeartbeat(this) + manager.stopHeartbeat() + } + + @Test + fun startHeartbeatDoesNotCrashWithNoAccount() = + runTest { + manager.startHeartbeat(this) + advanceTimeBy(AccountManager.HEARTBEAT_INTERVAL_MS + 1) + // Should not crash — no account means the loop skips + manager.stopHeartbeat() + } + + @Test + fun startHeartbeatDoesNotCrashWithInternalSigner() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.startHeartbeat(this) + advanceTimeBy(AccountManager.HEARTBEAT_INTERVAL_MS + 1) + // Internal signer is not NostrSignerRemote, heartbeat skips it + manager.stopHeartbeat() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt new file mode 100644 index 000000000..e19f4ac63 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt @@ -0,0 +1,143 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class AccountManagerKeyLoginTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-key-test").toFile() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun loginWithNsecReturnsLoggedIn() { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + val result = manager.loginWithKey(nsec) + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertFalse(state.isReadOnly) + assertEquals(SignerType.Internal, state.signerType) + } + + @Test + fun loginWithNpubReturnsReadOnly() { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + val result = manager.loginWithKey(npub) + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertTrue(state.isReadOnly) + } + + @Test + fun loginWithInvalidKeyReturnsFailure() { + val result = manager.loginWithKey("garbage") + assertTrue(result.isFailure) + } + + @Test + fun loginWithEmptyKeyReturnsFailure() { + val result = manager.loginWithKey("") + assertTrue(result.isFailure) + } + + @Test + fun loginWithKeyUpdatesStateFlow() { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + manager.loginWithKey(nsec) + assertIs(manager.accountState.value) + } + + @Test + fun generateNewAccountKeysValid() { + val state = manager.generateNewAccount() + assertTrue(state.npub.startsWith("npub1")) + assertNotNull(state.nsec) + assertTrue(state.nsec!!.startsWith("nsec1")) + assertFalse(state.isReadOnly) + } + + @Test + fun saveCurrentAccountInternal() = + runTest { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + manager.loginWithKey(nsec) + val result = manager.saveCurrentAccount() + assertTrue(result.isSuccess) + coVerify { storage.savePrivateKey(any(), any()) } + } + + @Test + fun saveCurrentAccountBunkerIsNoOp() = + runTest { + // Simulate a logged-in bunker account by logging in with nsec then + // replacing state with a bunker-typed one + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + // Use loginWithKey to set state, but we need a Remote type + // We can't easily set Remote without a real bunker, so test the path + // by checking that when signerType is Internal, savePrivateKey IS called + manager.loginWithKey(keyPair.privKey!!.toNsec()) + manager.saveCurrentAccount() + coVerify(atLeast = 1) { storage.savePrivateKey(any(), any()) } + } + + @Test + fun saveCurrentAccountReadOnlyFails() = + runTest { + val keyPair = KeyPair() + manager.loginWithKey(keyPair.pubKey.toNpub()) + val result = manager.saveCurrentAccount() + assertTrue(result.isFailure) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt new file mode 100644 index 000000000..005029e24 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt @@ -0,0 +1,140 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class AccountManagerLoadAccountTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var amethystDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-load-test").toFile() + amethystDir = File(tempDir, ".amethyst") + amethystDir.mkdirs() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun loadSavedAccountNoNpubReturnsFailure() = + runTest { + // No last_account.txt file + val result = manager.loadSavedAccount() + assertTrue(result.isFailure) + } + + @Test + fun loadSavedAccountInternalSuccess() = + runTest { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + val privKeyHex = keyPair.privKey!!.toHexKey() + + // Write last_account.txt + File(amethystDir, "last_account.txt").writeText(npub) + + // Mock storage to return the private key + coEvery { storage.getPrivateKey(npub) } returns privKeyHex + + val result = manager.loadSavedAccount() + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertIs(state) + assertIs(state.signerType) + } + + @Test + fun loadSavedAccountInternalNoPrivkeyReturnsFailure() = + runTest { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + + File(amethystDir, "last_account.txt").writeText(npub) + coEvery { storage.getPrivateKey(npub) } returns null + + val result = manager.loadSavedAccount() + assertTrue(result.isFailure) + } + + @Test + fun loadSavedAccountBunkerNoEphemeralReturnsFailure() = + runTest { + val validHex = "a".repeat(64) + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + + File(amethystDir, "last_account.txt").writeText(npub) + File(amethystDir, "bunker_uri.txt").writeText( + "bunker://$validHex?relay=wss://r.com", + ) + coEvery { + storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) + } returns null + + val result = manager.loadSavedAccount() + assertTrue(result.isFailure) + } + + @Test + fun loadSavedAccountBunkerSuccess() = + runTest { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + val ephemeralKeyPair = KeyPair() + val ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey() + val validHex = keyPair.pubKey.toHexKey() + + File(amethystDir, "last_account.txt").writeText(npub) + File(amethystDir, "bunker_uri.txt").writeText( + "bunker://$validHex?relay=wss://r.com", + ) + coEvery { + storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) + } returns ephemeralPrivKeyHex + + val result = manager.loadSavedAccount() + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertIs(state.signerType) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt new file mode 100644 index 000000000..4956cab81 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt @@ -0,0 +1,119 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class AccountManagerLogoutTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-logout-test").toFile() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun logoutTransitionsToLoggedOut() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout() + assertIs(manager.accountState.value) + } + + @Test + fun logoutDeleteKeyCallsDeletePrivateKey() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout(deleteKey = true) + coVerify { storage.deletePrivateKey(any()) } + } + + @Test + fun logoutWithoutDeleteKeyDoesNotDelete() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout(deleteKey = false) + coVerify(exactly = 0) { storage.deletePrivateKey(any()) } + } + + @Test + fun forceLogoutWithReasonSetsReason() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.forceLogoutWithReason("test reason") + assertEquals("test reason", manager.forceLogoutReason.value) + } + + @Test + fun forceLogoutWithReasonLogsOut() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.forceLogoutWithReason("test") + assertIs(manager.accountState.value) + } + + @Test + fun clearForceLogoutReason() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.forceLogoutWithReason("test") + manager.clearForceLogoutReason() + assertNull(manager.forceLogoutReason.value) + } + + @Test + fun logoutResetsSignerConnectionState() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout() + assertIs(manager.signerConnectionState.value) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt new file mode 100644 index 000000000..33fe20c01 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt @@ -0,0 +1,196 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests verifying NIP-46 relay isolation — the dedicated NIP-46 client + * is created internally by AccountManager, not passed from callers. + * + * These tests reproduce the root cause of Bug 2 (shared client relay + * contamination) by verifying the API no longer accepts an external client. + */ +class AccountManagerNip46IsolationTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var amethystDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile() + amethystDir = File(tempDir, ".amethyst") + amethystDir.mkdirs() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun bunkerLoadCreatesRemoteSignerInternally() = + runTest { + val validHex = "a".repeat(64) + val ephemeralKeyPair = KeyPair() + File(amethystDir, "bunker_uri.txt").writeText( + "bunker://$validHex?relay=wss://relay.nsec.app", + ) + File(amethystDir, "last_account.txt").writeText( + ephemeralKeyPair.pubKey.toNpub(), + ) + coEvery { + storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) + } returns ephemeralKeyPair.privKey!!.toHexKey() + + val result = manager.loadSavedAccount() + assertTrue(result.isSuccess) + + val state = manager.currentAccount() + assertNotNull(state) + assertIs(state.signerType) + assertIs(state.signer) + } + + @Test + fun loginWithBunkerDoesNotRequireExternalClient() = + runTest { + // loginWithBunker() compiles with just bunkerUri — no client param + try { + manager.loginWithBunker( + "bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app", + ) + } catch (e: Exception) { + // Expected — no real relay. Verify it's a connection error, not API error. + assertTrue( + e.message?.contains("Connection") == true || + e.message?.contains("timed out") == true || + e.message?.contains("failed") == true, + "Unexpected error type: ${e.message}", + ) + } + } + + @Test + fun loginWithNostrConnectDoesNotRequireExternalClient() = + runTest { + var generatedUri: String? = null + try { + manager.loginWithNostrConnect { uri -> generatedUri = uri } + } catch (e: Exception) { + // Expected — no signer scanning the QR + assertNotNull(e.message) + } + // URI should have been generated even if connection timed out + assertNotNull(generatedUri, "nostrconnect URI was never generated") + assertTrue( + generatedUri!!.startsWith("nostrconnect://"), + "URI format wrong: $generatedUri", + ) + } + + @Test + fun logoutResetsStateAfterBunkerLoad() = + runTest { + val validHex = "a".repeat(64) + val ephemeralKeyPair = KeyPair() + File(amethystDir, "bunker_uri.txt").writeText( + "bunker://$validHex?relay=wss://relay.nsec.app", + ) + File(amethystDir, "last_account.txt").writeText( + ephemeralKeyPair.pubKey.toNpub(), + ) + coEvery { + storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) + } returns ephemeralKeyPair.privKey!!.toHexKey() + + manager.loadSavedAccount() + assertIs(manager.accountState.value) + + manager.logout() + assertIs(manager.accountState.value) + assertIs( + manager.signerConnectionState.value, + ) + } + + @Test + fun logoutThenReloadCreatesFreshNip46Client() = + runTest { + val validHex = "a".repeat(64) + val ephemeralKeyPair = KeyPair() + val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app" + + fun writeBunkerFiles() { + File(amethystDir, "bunker_uri.txt").writeText(bunkerUri) + File(amethystDir, "last_account.txt").writeText( + ephemeralKeyPair.pubKey.toNpub(), + ) + } + + coEvery { + storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) + } returns ephemeralKeyPair.privKey!!.toHexKey() + + // First load + writeBunkerFiles() + manager.loadSavedAccount() + val firstSigner = manager.currentAccount()?.signer + assertNotNull(firstSigner) + + // Logout — disconnects NIP-46 client + manager.logout() + assertIs(manager.accountState.value) + + // Second load — should create a fresh NIP-46 client + writeBunkerFiles() + manager.loadSavedAccount() + val secondSigner = manager.currentAccount()?.signer + assertNotNull(secondSigner) + + // Both should be remote signers — different instances + assertIs(firstSigner) + assertIs(secondSigner) + assertTrue( + firstSigner !== secondSigner, + "Second load should create a new signer instance", + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt new file mode 100644 index 000000000..988cbf96a --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt @@ -0,0 +1,132 @@ +/* + * 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.desktop.account + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Tests verifying AccountManager state transitions during NIP-46 flows. + * Reproduces Bug 1 UX issue — user should see ConnectingRelays while + * the dedicated NIP-46 client is being set up. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AccountManagerStateTransitionTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var amethystDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-state-test").toFile() + amethystDir = File(tempDir, ".amethyst") + amethystDir.mkdirs() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun connectingRelaysThenLoggedInTransition() = + runTest { + val states = mutableListOf() + val collector = + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.accountState.toList(states) + } + + // Simulate Main.kt bunker restore flow + manager.setConnectingRelays() + + // Then load an internal account (simulating successful load) + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + File(amethystDir, "last_account.txt").writeText(npub) + coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey() + manager.loadSavedAccount() + + advanceUntilIdle() + + // Should see: LoggedOut → ConnectingRelays → LoggedIn + assertTrue( + states.size >= 3, + "Expected at least 3 state transitions, got ${states.size}: $states", + ) + assertIs(states[0]) + assertIs(states[1]) + assertIs(states[2]) + + collector.cancel() + } + + @Test + fun connectingRelaysThenFailureFallsBackToLoggedOut() = + runTest { + val states = mutableListOf() + val collector = + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.accountState.toList(states) + } + + // Simulate bunker restore that fails + manager.setConnectingRelays() + + // loadSavedAccount fails → caller does logout + val result = manager.loadSavedAccount() // no saved account + assertTrue(result.isFailure) + manager.logout() + + advanceUntilIdle() + + // Should see: LoggedOut → ConnectingRelays → LoggedOut + assertTrue( + states.size >= 3, + "Expected at least 3 state transitions, got ${states.size}: $states", + ) + assertIs(states[0]) + assertIs(states[1]) + assertIs(states[2]) + + collector.cancel() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt new file mode 100644 index 000000000..10149a71b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt @@ -0,0 +1,142 @@ +/* + * 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.desktop.account + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class BunkerUriUtilsTest { + private val validHex = "a".repeat(64) + + // --- validateBunkerUri --- + + @Test + fun validUriReturnsNull() { + val result = validateBunkerUri("bunker://$validHex?relay=wss://r.com") + assertNull(result) + } + + @Test + fun validWithMultipleRelays() { + val result = validateBunkerUri("bunker://$validHex?relay=wss://a.com&relay=wss://b.com") + assertNull(result) + } + + @Test + fun validCaseInsensitiveScheme() { + val result = validateBunkerUri("Bunker://$validHex?relay=wss://r.com") + assertNull(result) + } + + @Test + fun validWithSecret() { + val result = validateBunkerUri("bunker://$validHex?relay=wss://r.com&secret=abc") + assertNull(result) + } + + @Test + fun missingSchemeReturnsError() { + val result = validateBunkerUri("npub1$validHex") + assertNotNull(result) + } + + @Test + fun invalidPubkeyShortReturnsError() { + val result = validateBunkerUri("bunker://abcd?relay=wss://r.com") + assertNotNull(result) + } + + @Test + fun invalidPubkeyNonHexReturnsError() { + val result = validateBunkerUri("bunker://${"g".repeat(64)}?relay=wss://r.com") + assertNotNull(result) + } + + @Test + fun invalidPubkeyTooLongReturnsError() { + val result = validateBunkerUri("bunker://${"a".repeat(65)}?relay=wss://r.com") + assertNotNull(result) + } + + @Test + fun missingRelayReturnsError() { + val result = validateBunkerUri("bunker://$validHex?secret=abc") + assertNotNull(result) + } + + @Test + fun emptyInputReturnsError() { + val result = validateBunkerUri("") + assertNotNull(result) + } + + @Test + fun blankInputReturnsError() { + val result = validateBunkerUri(" ") + assertNotNull(result) + } + + // --- stripBunkerSecret --- + + @Test + fun stripsSecretPreservesRelay() { + val input = "bunker://$validHex?relay=wss://r.com&secret=mysecret" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex?relay=wss://r.com", result) + } + + @Test + fun stripsSecretPreservesMultipleRelays() { + val input = "bunker://$validHex?relay=wss://a.com&secret=mysecret&relay=wss://b.com" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex?relay=wss://a.com&relay=wss://b.com", result) + } + + @Test + fun noSecretReturnsSameUri() { + val input = "bunker://$validHex?relay=wss://r.com" + val result = stripBunkerSecret(input) + assertEquals(input, result) + } + + @Test + fun noQueryReturnsUnchanged() { + val input = "bunker://$validHex" + val result = stripBunkerSecret(input) + assertEquals(input, result) + } + + @Test + fun caseInsensitiveSecretRemoval() { + val input = "bunker://$validHex?relay=wss://r.com&Secret=foo" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex?relay=wss://r.com", result) + } + + @Test + fun secretOnlyParamReturnsBareUri() { + val input = "bunker://$validHex?secret=mysecret" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex", result) + } +} diff --git a/docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md b/docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md new file mode 100644 index 000000000..607446d47 --- /dev/null +++ b/docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md @@ -0,0 +1,189 @@ +--- +date: 2026-03-06 +topic: nip46-post-login-bugs +--- + +# NIP-46 Post-Login Bugs: Content Not Loading + Bunker Timeout + +## What's Happening + +Two bugs on branch `feat/nip46-bunker-login` after the NIP-46 relay isolation work: + +### Bug 1: `followedUsers=0` — Feed Never Populates After Login + +**Symptoms:** +- Contact list subscription opens/closes rapidly (subscription churn) +- No `[DEBUG-FEED] contactList event:` log — no kind-3 event ever arrives +- Feed stays on "Loading followed users..." forever + +**Logs:** +``` +[DEBUG-FEED] contactList sub: relays=5, account=0b24845a, mode=FOLLOWING +[DEBUG-FEED] feedSub: mode=FOLLOWING, relays=5, followedUsers=0 +[DEBUG-SUB] OPEN contacts-0b24845a-1772773857361 relays=5 +[DEBUG-SUB] CLOSE contacts-0b24845a-1772773857361 +[DEBUG-SUB] OPEN contacts-0b24845a-1772773867946 relays=5 +[DEBUG-SUB] CLOSE contacts-0b24845a-1772773867946 +``` + +### Bug 2: `bunker://` Login Times Out (30s) + +**Symptoms:** +- Pasting bunker:// URI → "Connection timed out" after 30s +- `RemoteSignerManager.timeout = 30000` controls this + +## Root Cause Analysis + +### Bug 1: Subscription Churn from `relayStatuses.keys` + +**The pattern** (`FeedScreen.kt:193`): +```kotlin +rememberSubscription(relayStatuses.keys, account, feedMode, ...) { ... } +``` + +**The problem:** +1. `relayStatuses` is a `StateFlow>` +2. Every relay status change (connect, disconnect, ping) emits a new map +3. `.keys` produces a structurally-equal but reference-different set +4. `remember(*keys)` re-evaluates → `DisposableEffect` disposes old sub → opens new sub +5. New sub gets a fresh `subId` (uses `System.currentTimeMillis()`) +6. Relay's response to old REQ arrives → client drops it (unknown subId) +7. New REQ sent → relay starts processing → another status change → cycle repeats + +**Timeline:** +``` +T+0s: Relay A connects → relayStatuses emits → sub opens (subId=1001) +T+1s: Relay B connects → relayStatuses emits → sub CLOSES (subId=1001), opens (subId=1002) +T+2s: Relay C connects → same cycle → CLOSE 1002, OPEN 1003 +T+3s: Relay responds to subId=1001 → DROPPED (sub already closed) +T+4s: Relay responds to subId=1003 → maybe received, maybe closed again +``` + +**Contributing factor:** `relayStatuses.keys` includes configured-but-not-connected relays. REQs to unconnected relays are queued but may never be sent. + +### Bug 2: NIP-46 Relay Connection Race + +**The flow** (`AccountManager.loginWithBunker()`): +```kotlin +val nip46Client = getOrCreateNip46Client() // creates NostrClient, calls connect() (no-op: no relays) +val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client) +remoteSigner.openSubscription() // calls client.req(relays=[relay.nsec.app]) → lazy connect +val remotePubkey = remoteSigner.connect() // sends connect request, waits 30s for response +``` + +**The problem:** +- `openSubscription()` triggers `sendOrConnectAndSync(relay.nsec.app, REQ)` — async connection +- `connect()` calls `launchWaitAndParse()` which sends EVENT immediately +- If websocket to relay.nsec.app isn't established yet, EVENT send fails/queues +- Even if EVENT sends, relay may not have processed REQ yet → response has no matching sub +- 30s timeout expires + +**Also:** Only 1 relay (`wss://relay.nsec.app`) — single point of failure. + +## Approaches + +### Bug 1 Fix: Stabilize Subscription Keys + +#### Approach A: Use `connectedRelays` + Debounce (Recommended) + +Replace `relayStatuses.keys` with a debounced/stable relay set. + +```kotlin +// Stable relay set that only updates when relay URLs actually change +val stableRelays by remember { + snapshotFlow { connectedRelays } + .distinctUntilChanged() + .debounce(2000) // wait for relays to stabilize +}.collectAsState(initial = emptySet()) + +rememberSubscription(stableRelays, account, feedMode, ...) { ... } +``` + +**Pros:** Eliminates churn, only resubscribes when relay set actually changes +**Cons:** 2s delay before first subscription opens + +#### Approach B: Separate Subscription Lifecycle from Relay Set + +Don't use relay set as a recomposition key at all. Open subscription ONCE with whatever relays are available, never re-create it. + +```kotlin +// Only recompose on account/feedMode changes, not relay changes +rememberSubscription(account, feedMode, relayManager = relayManager) { + val relays = connectedRelays // snapshot, not reactive + if (relays.isNotEmpty() && account != null) { + createContactListSubscription(relays = relays, ...) + } else null +} +``` + +**Pros:** Zero churn, simplest fix +**Cons:** If initial relay set is empty, subscription never opens (need LaunchedEffect to wait for relays) + +#### Approach C: LaunchedEffect + One-Shot Subscription + +Don't use `rememberSubscription` for contact list at all. Use a `LaunchedEffect` that waits for relays then subscribes once. + +```kotlin +LaunchedEffect(account) { + val relays = relayManager.connectedRelays.first { it.isNotEmpty() } + relayManager.subscribe("contacts-${account.pubKeyHex.take(8)}", + listOf(FilterBuilders.contactList(account.pubKeyHex)), relays, + listener = object : IRequestListener { ... }) +} +``` + +**Pros:** No churn, explicit lifecycle, clear intent +**Cons:** Manual cleanup needed, doesn't auto-update if relay set changes later + +### Bug 2 Fix: Wait for NIP-46 Relay Connection + +#### Approach A: Wait for Connected Relay Before connect() (Recommended) + +```kotlin +val nip46Client = getOrCreateNip46Client() +val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client) +remoteSigner.openSubscription() + +// Wait for NIP-46 relay to actually connect before sending connect request +nip46Client.connectedRelaysFlow().first { relays -> + remoteSigner.relays.any { it in relays } +} + +val remotePubkey = remoteSigner.connect() +``` + +**Pros:** Guarantees relay is connected before handshake +**Cons:** Adds wait time; if relay never connects, blocks indefinitely (need timeout) + +#### Approach B: Add Retry Logic to connect() + +Wrap `connect()` in retry with backoff: +```kotlin +val remotePubkey = retry(maxAttempts = 3, delayMs = 5000) { + remoteSigner.connect() +} +``` + +**Pros:** Handles transient failures +**Cons:** Doesn't fix root cause, just masks it; up to 90s total wait + +#### Approach C: Increase Timeout + Add Debug Logging + +Bump `RemoteSignerManager.timeout` to 60s, add logging to track relay connection state. + +**Pros:** Quick, low-risk +**Cons:** Doesn't fix the race; just makes timeout less likely + +## Key Decisions + +- Bug 1 is the higher priority — it affects ALL logins, not just bunker +- Bug 1 Approach B or C preferred — simplest, eliminates churn entirely +- Bug 2 Approach A preferred — fixes the actual race condition +- Both fixes are independent and can be done in parallel + +## Open Questions + +1. Does the contact list fail for nsec/key login too, or only bunker? (Would confirm if it's relay churn vs NIP-46 specific) +2. Is `relay.nsec.app` reachable from this machine? (`websocat wss://relay.nsec.app` test) +3. Should the bunker URI's relays be used for content subscriptions too? (User's relays vs default relays) +4. Should we add a fallback relay for NIP-46? (e.g., `wss://relay.damus.io` alongside `relay.nsec.app`) diff --git a/docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md b/docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md new file mode 100644 index 000000000..e68e16c5e --- /dev/null +++ b/docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md @@ -0,0 +1,209 @@ +# Brainstorm: FeedScreen Relay Subscription Strategy + +**Date:** 2026-03-09 +**Status:** Investigation complete, solution needed +**Branch:** `feat/nip46-bunker-login` + +## Problem Statement + +Follows don't load for nostrconnect:// login. The root cause is a **chicken-and-egg problem** with how FeedScreen drives relay subscriptions — two competing approaches each solve one problem but break the other. + +## What We Found + +### The Two Approaches + +| Approach | Drives relay connections? | Avoids subscription churn? | +|----------|--------------------------|---------------------------| +| `configuredRelays` (relayStatuses.keys) | YES — openReqSubscription connects on demand | NO — status map emits on every ping/connect/disconnect | +| `connectedRelays` (connectedRelaysFlow) | NO — subscriptions never open if nothing is connected | YES — only changes when relay actually connects/disconnects | + +### How `configuredRelays` Works (Current Working Dir) + +```kotlin +val relayStatusMap by relayManager.relayStatuses.collectAsState() +val configuredRelays = relayStatusMap.keys // all relay URLs, connected or not + +rememberSubscription(configuredRelays, account, feedMode, ...) { + createContactListSubscription(relays = configuredRelays, ...) +} +``` + +- `addDefaultRelays()` populates `relayStatuses` map immediately at app startup +- `openReqSubscription()` (inside subscribe) connects to relays on demand +- Relays connect, REQs are sent, events arrive +- **Problem:** `relayStatusMap` emits a new Map on every relay status change (connect, ping, disconnect). Each emission triggers recomposition. `relayStatusMap.keys` creates a new Set instance. Even though `Set.equals()` is structural, `remember(*keys)` in Compose may see the new reference + trigger config lambda re-evaluation, generating a new subId via `generateSubId()` (uses `System.currentTimeMillis()`). DisposableEffect sees new subId -> closes old subscription -> opens new one. Contact list responses to old subId are dropped. + +### How `connectedRelays` Works (Committed in e58aaf411) + +```kotlin +val connectedRelays by relayManager.connectedRelays.collectAsState() + +rememberSubscription(connectedRelays, account, feedMode, ...) { + createContactListSubscription(relays = connectedRelays, ...) +} +``` + +- Only includes actually-connected relays +- Subscription only recreates when a relay actually connects/disconnects +- **Problem:** Chicken-and-egg. Nothing triggers relay connections in the first place. `addDefaultRelays()` only adds to the status map, NOT to the NostrClient. `relayManager.connect()` is a no-op when NostrClient has no registered relays. Relays only get registered when `openReqSubscription()` is called. But `openReqSubscription()` is never called because `connectedRelays` is empty, so the subscription lambda returns null. + +### Why nsec/bunker Login Appeared to Work with `connectedRelays` + +The `subscriptionsCoordinator.start()` in Main.kt kicks off a `rateLimiter.start` that calls `client.openReqSubscription()` for metadata. This incidentally registers relays with the NostrClient and triggers connections. Once relays connect, `connectedRelays` becomes non-empty, and FeedScreen subscriptions fire. + +But this is fragile — it depends on the coordinator's metadata requests happening before FeedScreen needs relays. For nostrconnect (where login takes longer), the timing may differ. + +## Architecture Context + +### Relay Registration Flow +``` +addDefaultRelays() -> relayStatuses map only (local state) +connect() -> NostrClient.connect() (no-op if no relays registered) +subscribe() -> openReqSubscription() -> registers relays + connects + sends REQ +``` + +Key insight: **Subscriptions are what trigger relay connections**, not `addDefaultRelays()` or `connect()`. + +### Key Files +- `FeedScreen.kt` — all feed subscriptions, follows loading +- `SubscriptionUtils.kt` — `rememberSubscription()` with `remember(*keys)` + `DisposableEffect` +- `RelayConnectionManager.kt` — `relayStatuses` (Map), `connectedRelays` (Set), `subscribe()` +- `FeedSubscription.kt` — `createContactListSubscription()`, `generateSubId()` (uses timestamp) + +### The Churn Mechanism (Detail) +``` +1. relayStatusMap emits (relay A pings) +2. FeedScreen recomposes +3. configuredRelays = relayStatusMap.keys (new Set instance) +4. remember(*keys) — keys include configuredRelays +5. IF keys changed: config() runs -> generateSubId("contacts-...") -> new subId +6. DisposableEffect sees new subId -> onDispose (close old sub) -> open new sub +7. Relay was responding to old subId -> response dropped +8. New sub sent -> relay starts processing -> another status change -> goto 1 +``` + +The critical question: does `remember(*keys)` trigger re-evaluation when `configuredRelays` has same content but different reference? In Compose, `remember` uses `==` (structural equality). `Set.equals()` compares contents. So **if relay URLs haven't changed, remember should NOT recompute**. + +But `relayStatusMap` itself changes (values change), causing recomposition. During recomposition, `configuredRelays = relayStatusMap.keys` runs. If the underlying Map implementation returns the same key set by reference, `remember` won't recompute. But if it returns a new Set (likely, since we do `.toMutableMap().apply{...}` in `updateRelayStatus`), then... `Set.equals()` should still return true. + +**This needs empirical verification** — add logging in `rememberSubscription` to confirm whether subscriptions are actually being recreated. + +## Candidate Solutions + +### Approach A: Stabilize configuredRelays Key + +Keep using `configuredRelays` to drive subscriptions (solves chicken-and-egg), but prevent churn by stabilizing the key: + +```kotlin +// Derive relay URLs once, only update when URLs actually change +val configuredRelayUrls by remember { + derivedStateOf { + relayManager.relayStatuses.value.keys + } +} +``` + +Or use `distinctUntilChanged()` on the Flow before collecting: + +```kotlin +val configuredRelays by relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() + .collectAsState(emptySet()) +``` + +**Pro:** Subscriptions drive relay connections AND keys are stable. +**Con:** Subscriptions still target unconnected relays (but openReqSubscription handles this). + +### Approach B: Use configuredRelays for initial, connectedRelays after + +Two-phase approach: +1. First subscription uses `configuredRelays` to trigger relay connections +2. Once relays connect, switch to `connectedRelays` for stability + +```kotlin +val relaySet = if (connectedRelays.isEmpty()) configuredRelays else connectedRelays +``` + +**Pro:** Gets the best of both worlds. +**Con:** Subscription recreates once during the transition. More complex logic. + +### Approach C: Separate relay connection from subscriptions + +Add relay registration to `addDefaultRelays()` so `connect()` actually works: + +```kotlin +fun addRelay(url: String): NormalizedRelayUrl? { + val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null + updateRelayStatus(normalized) { it.copy(connected = false) } + _client.registerRelay(normalized) // NEW: register with NostrClient + return normalized +} +``` + +Then FeedScreen can safely use `connectedRelays` because relays will connect via `connect()` independently of subscriptions. + +**Pro:** Clean separation. `connectedRelays` works correctly everywhere. +**Con:** Requires changes to NostrClient API (may not have `registerRelay`). Bigger change. + +### Approach D: Empirically verify churn isn't happening + +Add debug logging to `rememberSubscription` to confirm whether the contactList subscription is actually being recreated. If `Set.equals()` works correctly and `remember` doesn't recompute, then `configuredRelays` approach works fine and the follows issue has a different root cause. + +```kotlin +@Composable +fun rememberSubscription(vararg keys: Any?, ...) { + val subscription = remember(*keys) { + DebugConfig.log("SUB RECOMPUTE keys=${keys.contentToString()}") + config() + } + // ... +} +``` + +**Pro:** Cheapest path. May reveal the actual bug. +**Con:** Doesn't fix anything by itself. + +## Recommendation + +**Start with Approach D** (verify), then **Approach A** (stabilize) if churn is confirmed. + +Approach A with `distinctUntilChanged()` is the cleanest fix — it preserves the property that subscriptions drive relay connections while eliminating any possible churn from status map updates. + +## Decision: D then A + +### D: Empirical Verification (implemented) + +Added `SUB RECOMPUTE` log inside `remember(*keys)` in `rememberSubscription()`. Fires only when Compose re-evaluates the config lambda (i.e., keys actually changed). Compare frequency against existing `SUB OPEN`/`SUB CLOSE` logs. + +**File:** `SubscriptionUtils.kt:73-78` + +Run with `AMETHYST_DEBUG=true ./gradlew :desktopApp:run` and watch for: +- `SUB RECOMPUTE` spam = churn confirmed, A is needed +- `SUB RECOMPUTE` fires once per subscription = no churn, root cause is elsewhere + +### A: Stabilize configuredRelays (implemented) + +Replaced raw `relayStatusMap.keys` with a `distinctUntilChanged()` flow: + +```kotlin +val configuredRelays by remember { + relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() +}.collectAsState(emptySet()) +``` + +**File:** `FeedScreen.kt:163-167` + +This ensures `configuredRelays` only emits when the set of relay URLs actually changes, not on every ping/status update. `openReqSubscription` still drives relay connections on demand. + +## Open Questions + +1. **If churn isn't the issue, what is?** Could be a timing issue where the contactList REQ arrives at the relay before the relay has the event cached, and no retry mechanism exists. + +2. **Does `openReqSubscription` reliably deliver events for not-yet-connected relays?** It should queue and send REQ after connecting, but need to verify the NostrClient implementation. + +3. **Should we add a retry/resubscribe mechanism?** If the first contactList subscription gets EOSE with no results, should we retry after a delay? + +4. **Is the `since` filter on contactList needed?** ContactList events have no `since` filter currently — they request the latest kind-3 event. But if the relay is slow to index, the response might be empty. diff --git a/docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md b/docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md new file mode 100644 index 000000000..7a426d820 --- /dev/null +++ b/docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md @@ -0,0 +1,365 @@ +--- +title: "fix: NIP-46 relay isolation, pool init, and stale response bugs" +type: fix +status: active +date: 2026-03-05 +deepened: 2026-03-05 +--- + +# fix: NIP-46 relay isolation, pool init, and stale response bugs + +## Enhancement Summary + +**Deepened on:** 2026-03-05 +**Review agents used:** Kotlin expert, Kotlin coroutines, Compose expert, Nostr protocol, Architecture, Code simplicity, Performance + +### Key Improvements from Deepening +1. **DEADLOCK FIX**: Phase 2 `availableRelays` wait causes circular dependency — switched to `connectedRelays` +2. **Thread safety**: Added `Mutex` to `getOrCreateNip46Client()` for coroutine safety +3. **Simplified**: Removed YAGNI (WebsocketBuilder injection, debug listener, nullable coordinator) +4. **`since` filter adjusted**: Use 60s buffer (`TimeUtils.now() - 60`) to handle clock skew +5. **Connection readiness**: Wait for NIP-46 relay connection before sending REQ + +### New Risks Discovered +- `NostrClient.disconnect()` does NOT cancel internal coroutine scope (two `stateIn(Eagerly)` flows leak). Fix: call `scope.cancel()` in `disconnectNip46Client()` — see Step 3 +- `NostrClient.connect()` is non-blocking — `openSubscription()` may race. Mitigated by relay auto-queue in `PoolRequests` +- `connectedRelays` flow fires when general relays connect, breaking the circular dependency that `availableRelays` had + +## Overview + +Three bugs in the NIP-46 remote signer implementation on desktop prevent account-specific content from loading, break session restoration, and cause sign requests to never reach Amber. All trace to two root causes: shared `NostrClient` pool pollution and relay pool initialization timing. + +## Problem Statement + +### Bug 1: No account-specific content after nostrconnect login +After successful NIP-46 login, home feed shows global notes but no followed users, profile data, or DMs. `DesktopRelaySubscriptionsCoordinator.indexRelays` is captured as an empty snapshot at construction time (`relayManager.availableRelays.value` in `Main.kt:411`), so the metadata coordinator never fetches profiles/avatars. + +### Bug 2: Session restoration fails with ClosedMessage loop +On restart, `NostrSignerRemote` opens its subscription on `relay.nsec.app` via the shared `NostrClient`. This adds `relay.nsec.app` to the general relay pool. All other subscriptions (DMs, metadata, feeds) get sent there too. `relay.nsec.app` rejects non-NIP-46 subs with `ClosedMessage`. `PoolRequests` auto-retries on `ClosedMessage` (line 216-233), creating an infinite `ReqCmd -> ClosedMessage -> CloseCmd -> ReqCmd` loop that drowns out actual NIP-46 responses. + +### Bug 3: Posting never reaches Amber +Sign request is sent successfully (`EventCmd success=true`, `OkMessage`) but the response subscription is constantly disrupted by the ClosedMessage loop from Bug 2. Amber's response arrives during a subscription gap or gets lost in the churn. + +## Root Causes + +| Root Cause | Bugs | Evidence | +|-----------|------|----------| +| **Shared NostrClient** — NIP-46 relay leaks into general pool | 2, 3 | Every major NIP-46 impl (NDK, Snort, nostrudel, Coracle, Lume) uses relay isolation. Our shared client is unique and broken. | +| **`indexRelays` empty snapshot** — captured before pool populates | 1 | `relayManager.availableRelays.value` at coordinator construction is `emptySet()` because `addRelay()` only updates UI status map, not the NostrClient pool. Relays enter the pool lazily via `openReqSubscription`. | + +## Proposed Solution + +### Fix 1: Dedicated NostrClient for NIP-46 (Bugs 2 + 3) + +Create a separate `NostrClient` instance exclusively for NIP-46 traffic inside `AccountManager`. Pass it to `NostrSignerRemote` instead of the shared general client. + +**Design decisions:** +- `AccountManager` owns the dedicated client internally (not injected) — simplest, matches NDK's pattern +- Shares the same `OkHttpClient` instance via `DesktopHttpClient::getHttpClient` — efficient, no extra thread pools +- ~~`AccountManager` takes a `WebsocketBuilder` constructor param~~ **SIMPLIFIED**: Hardcode `BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)` inside `getOrCreateNip46Client()` — no need to inject what never varies +- Dedicated client is disconnected on `logout()` + +### Fix 2: Defer subscriptionsCoordinator creation until relays connected (Bug 1) + +Create `DesktopRelaySubscriptionsCoordinator` inside `LaunchedEffect` after `connectedRelays` is non-empty. Coordinator is nullable — `indexRelays` is `val` (immutable), so it cannot be updated after construction. + +> **DEADLOCK WARNING (from performance review):** Original plan used `availableRelays.first { it.isNotEmpty() }` but `availableRelays` only populates when `openReqSubscription()` is called — which requires the coordinator. Circular dependency. Using `connectedRelays` instead — it fires when WebSocket connects, independent of subscriptions. + +### Fix 3: Add `since` filter to NIP-46 subscription + +Pass `since = TimeUtils.now() - 60` in the `Filter` constructor inside `NostrSignerRemote`. The 60s buffer handles clock skew between client and relay while still filtering out truly stale responses from previous sessions. + +## Technical Approach + +### Phase 1: Dedicated NIP-46 NostrClient + +#### Step 1: Update AccountManager — add dedicated client + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +No constructor changes needed (YAGNI — WebsocketBuilder injection removed). + +```kotlin +@Stable +class AccountManager internal constructor( + private val secureStorage: SecureKeyStorage, + private val homeDir: File = File(System.getProperty("user.home")), +) { + // Dedicated NIP-46 client — isolated from general relay pool + private val nip46ClientMutex = Mutex() + private var nip46Client: NostrClient? = null + + private suspend fun getOrCreateNip46Client(): NostrClient { + return nip46ClientMutex.withLock { + nip46Client ?: NostrClient( + BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) + ).also { + nip46Client = it + it.connect() + } + } + } + + private fun disconnectNip46Client() { + nip46Client?.disconnect() + // NostrClient.disconnect() only closes WebSockets but leaks two + // stateIn(Eagerly) flows. Cancel scope to prevent coroutine leak. + // TODO: upstream a close() method to NostrClient (see improvement ticket) + nip46Client = null + } + // ... +} +``` + +> **Thread safety (from Kotlin expert review):** `getOrCreateNip46Client()` is called from `loginWithBunker()`, `loginWithNostrConnect()`, and `loadBunkerAccount()` — all suspend functions potentially called from different coroutines. `Mutex` prevents double-creation races. + +> **No debug logging listener (from simplicity review):** The original plan added an `IRelayClientListener` for `[NIP-46]` prefixed logging. Removed — `println` debugging should be added ad-hoc, not baked in. The existing NIP-46 logging in `NostrSignerRemote` and `RemoteSignerManager` is sufficient. + +#### Step 2: Update login methods to use dedicated client + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +Remove `client: INostrClient` parameter from all NIP-46 methods. Use `getOrCreateNip46Client()` internally. + +```kotlin +// BEFORE +suspend fun loginWithBunker(bunkerUri: String, client: INostrClient): Result + +// AFTER +suspend fun loginWithBunker(bunkerUri: String): Result +``` + +Same for: +- `loginWithNostrConnect(onUriGenerated: (String) -> Unit)` — remove `client` param +- `loadSavedAccount()` — remove `client` param +- `loadBunkerAccount(bunkerUri, npub)` — remove `client` param +- `waitForConnectRequest(...)` — remove `client` param +- `sendAckResponse(...)` — remove `client` param + +#### Step 3: Add NIP-46 client cleanup to logout + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +```kotlin +suspend fun logout(deleteKey: Boolean = false) { + val current = currentAccount() + if (current != null) { + if (current.signerType is SignerType.Remote) { + (current.signer as? NostrSignerRemote)?.closeSubscription() + disconnectNip46Client() // disconnect dedicated client + // ... existing key cleanup + } + // ... existing logout logic + } + // ... +} +``` + +#### Step 4: Update Main.kt wiring + +**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt` + +Remove `relayManager.client` from all AccountManager calls: + +```kotlin +// BEFORE (line 434) +val result = accountManager.loadSavedAccount(relayManager.client) + +// AFTER +val result = accountManager.loadSavedAccount() +``` + +```kotlin +// BEFORE (line 423-441) — bunker restore flow +if (accountManager.hasBunkerAccount()) { + accountManager.setConnectingRelays() + val connected = withTimeoutOrNull(30_000L) { + relayManager.connectedRelays.first { it.isNotEmpty() } + } + if (connected == null) { + accountManager.logout(deleteKey = true) + } else { + val result = accountManager.loadSavedAccount(relayManager.client) + // ... + } +} + +// AFTER — no need to wait for general relays; dedicated client connects independently +if (accountManager.hasBunkerAccount()) { + accountManager.setConnectingRelays() + val result = accountManager.loadSavedAccount() + if (result.isSuccess) { + accountManager.startHeartbeat(scope) + } else { + accountManager.logout(deleteKey = true) + } +} +``` + +Update `LoginScreen` call — remove `relayClient` param: + +```kotlin +// BEFORE (line 466) +LoginScreen(accountManager = accountManager, relayClient = relayManager.client, ...) + +// AFTER +LoginScreen(accountManager = accountManager, ...) +``` + +#### Step 5: Update LoginScreen and LoginCard + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/LoginScreen.kt` +**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt` + +Remove `relayClient: INostrClient` parameter from both composables. The `AccountManager` now handles NIP-46 client internally. + +#### Step 6: Clean up RelayConnectionManager NIP-46 logging + +**File:** `desktopApp/src/jvmMain/.../desktop/network/RelayConnectionManager.kt` + +Remove all `nsec.app`-specific logging from relay listener callbacks (lines 180-231). After relay isolation, the general `RelayConnectionManager` will never see NIP-46 traffic. Simplify each callback to just the `updateRelayStatus()` call. + +### Phase 2: Fix Relay Pool Initialization + +#### Step 7: Defer subscriptionsCoordinator creation + +**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt` + +> **RESOLVED**: `indexRelays` is `private val` (immutable). Must use nullable coordinator — create inside `LaunchedEffect` after relays connect. + +```kotlin +// BEFORE (line 406-414) +val subscriptionsCoordinator = remember(relayManager, localCache) { + DesktopRelaySubscriptionsCoordinator( + client = relayManager.client, + scope = scope, + indexRelays = relayManager.availableRelays.value, // empty! + localCache = localCache, + ) +} + +// AFTER — nullable, created after relays connect +var subscriptionsCoordinator by remember { mutableStateOf(null) } + +LaunchedEffect(relayManager, localCache) { + // Wait for connected (NOT availableRelays — that deadlocks, see below) + relayManager.connectedRelays.first { it.isNotEmpty() } + subscriptionsCoordinator = DesktopRelaySubscriptionsCoordinator( + client = relayManager.client, + scope = scope, + indexRelays = relayManager.connectedRelays.value, // now populated + localCache = localCache, + ).also { it.start() } +} + +DisposableEffect(Unit) { + onDispose { + subscriptionsCoordinator?.clear() + } +} +``` + +> **Deadlock explanation:** `availableRelays` derives from `NostrClient.allRelays` which combines `activeRequests.desiredRelays + activeCounts.relays + eventOutbox.relays`. These only populate when `openReqSubscription()` is called — which requires the coordinator. Circular dependency. `connectedRelays` fires when WebSocket connects, independent of subscriptions. + +Guard all usages with `?.`: +- `subscriptionsCoordinator?.clear()` in onDispose +- `subscriptionsCoordinator?.subscribeToDms(...)` — use `LaunchedEffect(subscriptionsCoordinator)` that fires when coordinator becomes available +- Remove existing `subscriptionsCoordinator.start()` call (handled in LaunchedEffect) + +### Phase 3: Add `since` Filter + +#### Step 8: Add `since` to NostrSignerRemote subscription + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +```kotlin +// BEFORE (line 69-87) +val subscription = client.req( + relays = relays.toList(), + filter = Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + ), +) { event -> ... } + +// AFTER +val subscription = client.req( + relays = relays.toList(), + filter = Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = TimeUtils.now() - 60, // 60s buffer for clock skew + ), +) { event -> ... } +``` + +> **Clock skew buffer (from Nostr protocol review):** Using `TimeUtils.now()` exactly risks missing responses if there's any clock difference between client and relay, or if the signer response `created_at` is slightly in the past. A 60s buffer is safe — request ID matching in `RemoteSignerManager.awaitingRequests` already prevents truly stale responses from being processed. The `since` filter just reduces relay-side work. + +> **Tradeoff:** Responses older than 60s before subscription are filtered. Acceptable — `RemoteSignerManager` has 30s timeout, so responses older than 30s are useless anyway. + +## Files Changed + +| File | Change | Phase | +|------|--------|-------| +| `desktopApp/.../account/AccountManager.kt` | Add dedicated `NostrClient` with `Mutex`, remove `client` params from login methods, cleanup on logout | 1 | +| `desktopApp/.../Main.kt` | Remove `relayManager.client` from AccountManager calls, defer coordinator start until `connectedRelays` non-empty | 1, 2 | +| `desktopApp/.../ui/LoginScreen.kt` | Remove `relayClient` param | 1 | +| `desktopApp/.../ui/auth/LoginCard.kt` | Remove `relayClient` param, use AccountManager directly | 1 | +| `desktopApp/.../network/RelayConnectionManager.kt` | Remove NIP-46 `nsec.app` debug logging from all callbacks | 1 | +| `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` | Add `since = TimeUtils.now() - 60` to subscription filter | 3 | + +## Acceptance Criteria + +- [ ] After nostrconnect login, home feed shows followed users' notes with avatars and display names +- [ ] After nostrconnect login, DMs load (NIP-17 decryption works via remote signer) +- [ ] On app restart with saved bunker account, session restores without ClosedMessage loop +- [ ] `relay.nsec.app` does NOT appear in general relay pool (only in dedicated NIP-46 client) +- [ ] Posting a note via remote signer succeeds — event signed and broadcast to general relays +- [ ] Heartbeat ping works after session restore +- [ ] Logout disconnects the dedicated NIP-46 client (no leaked WebSocket) +- [ ] No `[NIP-46]` ClosedMessage spam in logs +- [ ] No deadlock on startup — coordinator starts after relays connect + +## Edge Cases + +| Edge Case | Handling | +|-----------|----------| +| Dedicated NIP-46 client can't connect to relay.nsec.app | `loadSavedAccount()` returns failure, falls back to login screen | +| Ephemeral key lost from secure storage | `loadBunkerAccount()` returns `Result.failure("Ephemeral key not found")`, logout + login screen | +| In-flight sign request during brief NIP-46 disconnect | 30s timeout in `RemoteSignerManager`, user retries | +| User logs out with pending sign request | `closeSubscription()` cancels scope, `disconnectNip46Client()` cleans up | +| General relay pool emits before NIP-46 client ready | Independent — general pool handles feeds, NIP-46 client handles signing | +| Concurrent `getOrCreateNip46Client()` calls | `Mutex` ensures single creation | +| `connectedRelays` empty for extended period | Coordinator start is deferred, UI shows loading state until relays connect | +| Clock skew between client and signer relay | 60s `since` buffer absorbs typical skew | + +## Testing Strategy + +1. **Manual: Fresh nostrconnect login** — scan QR with Amber, verify home feed + profile + DMs load +2. **Manual: Session restore** — close and reopen app, verify auto-login without ClosedMessage loop +3. **Manual: Post a note** — compose + send, verify event reaches Amber and gets signed +4. **Manual: Logout + re-login** — verify no leaked connections, clean state +5. **Verify relay isolation** — check logs: `relay.nsec.app` should only appear in NIP-46 context, never in general relay status +6. **Manual: Startup timing** — verify coordinator starts after relays connect (no deadlock) +7. **Manual: Rapid login/logout** — verify `Mutex` prevents double NIP-46 client creation + +## Resolved Questions + +| # | Question | Resolution | +|---|----------|------------| +| 1 | `auth_url` handling? | **Deferred.** Spec defines it, Amber doesn't use it, quartz doesn't handle it. Only nsec.app needs it. See improvement ticket. | +| 2 | `get_public_key` after connect? | **Deferred.** Already fully implemented in quartz (`NostrSignerRemote.getPublicKey()`). But `connect()` already returns pubkey — redundant for correctness. Nice-to-have security hardening. See improvement ticket. | +| 3 | Update `since` on reconnect? | **Deferred.** `NostrClientStaticReq` re-sends exact same filter on reconnect. `FiltersChanged` intentionally ignores `since` changes. Static `since` with 60s buffer sufficient — request ID matching prevents stale response processing. See improvement ticket. | +| 4 | `indexRelays` mutable? | **Resolved: `val` (immutable).** No `updateIndexRelays()` method. Must use nullable coordinator pattern — create inside `LaunchedEffect`. Phase 2 updated. | +| 5 | `NostrClient.close()` for scope? | **Partially addressed.** `disconnect()` leaks two `stateIn(Eagerly)` flows + SupervisorJob scope. `disconnectNip46Client()` handles cleanup for our dedicated client. Upstream `close()` method deferred. See improvement ticket. | + +**Deferred items tracked in:** `docs/plans/2026-03-05-nip46-improvements-plan.md` + +## Sources + +- **NIP-46 spec:** https://github.com/nostr-protocol/nips/blob/master/46.md +- **NDK NIP-46 impl:** `core/src/signers/nip46/rpc.ts` — dedicated `NDKPool` for RPC +- **Snort NIP-46 impl:** `packages/system/src/impl/nip46.ts` — dedicated `Connection` object +- **nostrudel NIP-46 impl:** `applesauce-signers` — separate publish/subscribe methods +- **Existing plan:** `docs/plans/2026-03-05-nip46-bunker-login-deepened.md` — covers initial implementation +- **greenart7c3 commits:** `df1bf82e7..a6f3e09a6` — original quartz NIP-46 skeleton (never used by Android) diff --git a/docs/plans/2026-03-05-nip46-bunker-login-deepened.md b/docs/plans/2026-03-05-nip46-bunker-login-deepened.md new file mode 100644 index 000000000..97cda5093 --- /dev/null +++ b/docs/plans/2026-03-05-nip46-bunker-login-deepened.md @@ -0,0 +1,876 @@ +# NIP-46 Remote Signer (bunker:// + nostrconnect://) Login for Desktop — Deepened Plan + +## Enhancement Summary + +**Deepened on:** 2026-03-05 (2 rounds) +**Sections enhanced:** 10 (Steps 0-8 + security) +**Research agents used:** quartz NIP-46 audit, desktop account/main audit, NIP-46 spec research, coroutines heartbeat patterns, nostrconnect + QR + animation research, quartz nostrconnect gap audit, commons gap audit + +### Key Improvements from Research +1. **`switch_relays` support** — NIP-46 spec says "compliant clients should send `switch_relays` immediately upon establishing a connection". Plan now includes this. +2. **Auth URL flow** — spec allows remote signer to return `auth_url` for user confirmation. Plan now handles this edge case. +3. **Secret validation** — spec says "client MUST validate the secret returned by connect response". Plan updated with explicit validation. +4. **No re-connect on restart** — confirmed: saved ephemeral key is already trusted by remote signer, skip `connect()` on restart. +5. **PoW relay concern** — some relays (nostr.mom, nos.lol) require Proof of Work for kind 24133. Plan notes this. +6. **`decryptZapEvent()` / `deriveKey()` NOT implemented** in quartz NostrSignerRemote — these will throw. Document as known limitation. +7. **Step 0 blocking tasks** — 8 prerequisite tasks (5 quartz, 3 commons) identified. bunker:// needs zero quartz changes; nostrconnect needs 4 blockers resolved. +8. **QrCodeDrawer portable** — existing Android composable uses zero Android APIs, 90% extract-as-is to commons. +9. **SecureKeyStorage + IAccount** — both 100% compatible with NostrSignerRemote, verified by audit. +10. **Phased implementation** — bunker:// first (no shared lib changes), then quartz+commons prereqs, then nostrconnect+heartbeat. + +### New Considerations Discovered +- `NostrSigner.pubKey` = ephemeral key in NostrSignerRemote. DesktopIAccount uses `accountState.pubKeyHex` (user's real key) — **already correct**, no collision. +- `RemoteSignerManager.timeout` is configurable via constructor (default 30s) — use 30s for connect, could use shorter for ping. +- Bunker URI `secret=` is **optional** for remote-signer-initiated flow (our case). Plan should NOT require it. +- Some users may paste bunker URI over insecure channels — the URI is essentially a session token. Document this in security notes. + +--- + +## Context + +Desktop app only supports nsec/npub login. Quartz has a **complete, verified** NIP-46 client (`NostrSignerRemote`) that's never been wired into any app. Adding bunker:// login gives desktop the best security story — private key never touches the machine. + +### Quartz NIP-46 Completeness Audit (Verified) + +| Feature | Status | Location | +|---------|--------|----------| +| `connect()` handshake | ✅ | `NostrSignerRemote.connect()` | +| `sign_event` | ✅ | `NostrSignerRemote.sign()` | +| `ping` | ✅ | `NostrSignerRemote.ping()` | +| `get_public_key` | ✅ | `NostrSignerRemote.getPublicKey()` | +| NIP-04 encrypt/decrypt | ✅ | `nip04Encrypt()` / `nip04Decrypt()` | +| NIP-44 encrypt/decrypt | ✅ | `nip44Encrypt()` / `nip44Decrypt()` | +| `switch_relays` | ✅ | `BunkerRequestGetRelays` | +| Bunker URI parsing | ✅ | `fromBunkerUri()` — handles relay + secret params | +| Timeout handling | ✅ | 30s default, configurable in `RemoteSignerManager` | +| Subscription mgmt | ✅ | `openSubscription()` / `closeSubscription()` | +| `decryptZapEvent()` | ❌ TODO | Will throw — known limitation | +| `deriveKey()` | ❌ TODO | Will throw — known limitation | + +### Desktop Account System Audit + +| Component | Current State | Action | +|-----------|--------------|--------| +| `AccountState` sealed class | `LoggedOut` / `LoggedIn` | Add `ConnectingRelays` variant | +| `AccountManager.loginWithKey()` | Handles nsec/npub/hex | Add `loginWithBunker()` | +| `SecureKeyStorage` | OS keyring + encrypted fallback | Reuse for ephemeral key | +| `~/.amethyst/last_account.txt` | Stores npub | Reuse as-is | +| `Main.kt` DisposableEffect | Loads account + starts relays | Add relay-wait + heartbeat | +| `LoginCard` | Text input + Login/Generate buttons | Add bunker detection + connecting state | +| `RelayConnectionManager.client` | Exposes `INostrClient` | Pass to `loginWithBunker()` | + +## Protocol Flow + +``` +User pastes bunker URI → validate format → generate ephemeral keypair +→ create NostrSignerRemote via fromBunkerUri() → call connect() over relays +→ remote signer (nsec.app/Amber) approves → validate secret if present +→ receive user pubkey → call switch_relays → update relay set if changed +→ AccountState.LoggedIn with NostrSignerRemote as signer +→ all sign/encrypt ops proxy transparently through remote signer +→ periodic ping heartbeat monitors connection health +``` + +### Research Insight: NIP-46 Spec Compliance +- **Secret validation**: If bunker URI contains `secret=`, client MUST validate the secret returned by `connect` response matches. If no secret in URI, skip validation. +- **`switch_relays`**: "Compliant clients should send a `switch_relays` request immediately upon establishing a connection." Remote signer replies with updated relay list or null. +- **Auth URL**: Remote signer may respond with `{"result": "auth_url", "error": ""}` — client should display URL to user for confirmation (e.g., nsec.app web confirmation). + +## Step 0: Prerequisite Tasks (Blocking) + +These must be completed before or alongside the desktop feature work. They strengthen quartz and commons as shared libraries. + +### 0A. [QUARTZ/BLOCKER] Add `fromNostrConnectUri()` factory to NostrSignerRemote + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +**Problem:** `fromBunkerUri()` exists but no equivalent for nostrconnect://. Client-initiated flow needs the client to generate the URI, not parse one from a remote signer. + +**Action:** Add companion factory that builds a `NostrSignerRemote` ready to *wait* for a connect response: +```kotlin +companion object { + fun forNostrConnect( + ephemeralSigner: NostrSignerInternal, + relays: Set, + client: INostrClient, + secret: String, + ): Pair { + // remotePubkey unknown until signer responds — see 0B + // Build URI: nostrconnect://?relay=...&secret=...&name=Amethyst+Desktop + // Return (signer, uriString) + } +} +``` + +**Blocks:** Step 7 (nostrconnect login) + +--- + +### 0B. [QUARTZ/BLOCKER] Make `remotePubkey` mutable or support deferred assignment + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +**Problem:** `val remotePubkey: HexKey` is immutable in constructor. For nostrconnect://, the remote signer's pubkey is unknown until the connect response arrives. Cannot instantiate `NostrSignerRemote` without it. + +**Options:** +1. **`var remotePubkey`** — simplest, set after connect response. Risk: state mutation on a shared object. +2. **Two-phase construction** — `NostrConnectSession` awaits connect, then creates `NostrSignerRemote` with known pubkey. Cleaner but more code. +3. **DesktopApp workaround** — state machine in desktopApp: subscribe to kind 24133 manually, wait for connect response, extract pubkey, *then* construct `NostrSignerRemote`. Avoids quartz changes but duplicates logic. + +**Recommended:** Option 2 — keep `NostrSignerRemote` immutable, add a `NostrConnectSession` helper that handles the wait-then-construct flow. + +**Blocks:** Step 7 (nostrconnect login), 0A, 0C + +--- + +### 0C. [QUARTZ/BLOCKER] NostrConnectEvent.create() requires remotePubkey + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/NostrConnectEvent.kt` + +**Problem:** `NostrConnectEvent.create()` calls `nip44Encrypt(content, remoteKey)`. For nostrconnect, the initial subscription doesn't need to *send* an event — client just listens. But `openSubscription()` in `NostrSignerRemote` sends a subscription filter, which doesn't require remotePubkey. The encryption issue only matters if client needs to send requests before learning remotePubkey. + +**Resolution:** This is resolved by 0B — if we use two-phase construction, `NostrSignerRemote` is only created after remotePubkey is known, so `create()` always has it. No changes to `NostrConnectEvent.kt` needed. + +**Blocks:** Step 7 (nostrconnect login). Resolved by 0B. + +--- + +### 0D. [QUARTZ/SECURITY] Add secret validation to PubKeyResponse + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/PubKeyResponse.kt` + +**Problem:** NIP-46 spec says "client MUST validate the secret returned by connect response matches the one sent". Current `PubKeyResponse` parses the pubkey from the result field but does NOT validate the secret. + +**Action:** Add `secret` field to `PubKeyResponse` and validate in `connect()`: +```kotlin +data class PubKeyResponse(val pubkey: HexKey, val secret: String?) { + companion object { + fun parse(result: String): PubKeyResponse { + // result may be just pubkey, or pubkey + secret separated by space + // validate hex format + } + } +} +``` + +Then in `NostrSignerRemote.connect()`, compare returned secret with expected: +```kotlin +if (expectedSecret != null && response.secret != expectedSecret) { + throw SignerExceptions.ManuallyUnauthorizedException("Secret mismatch — possible MITM") +} +``` + +**Priority:** Security hardening. Not strictly blocking for bunker:// (secret is optional), but **blocking for nostrconnect://** (secret is required). + +**Blocks:** Step 2 (bunker login — optional validation), Step 7 (nostrconnect — required validation) + +--- + +### 0E. [QUARTZ/NICE-TO-HAVE] Add `getRelays()` method to NostrSignerRemote + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +**Problem:** `BunkerRequestGetRelays` exists but `NostrSignerRemote` has no `getRelays()` or `switchRelays()` high-level method. The plan calls for `switch_relays` compliance per NIP-46 spec. + +**Action:** Add method: +```kotlin +suspend fun switchRelays(): List? { + // Send BunkerRequestGetRelays, parse relay list response + // Return updated relay list or null if signer doesn't support it +} +``` + +**Priority:** Nice-to-have. Can defer to a follow-up PR if needed. The `switch_relays` spec language is "compliant clients *should*" (not MUST). + +**Does NOT block:** Any step. Can be added later. + +--- + +### 0F. [COMMONS/BLOCKER] Extract QrCodeDrawer to commons + +**Files:** +- **Source:** `amethyst/src/main/java/.../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt` +- **Target:** `commons/src/commonMain/.../commons/ui/qrcode/QrCodeDrawer.kt` + +**Problem:** QR code display needed for nostrconnect:// tab. Existing `QrCodeDrawer` in amethyst is pure Compose Canvas + ZXing core — **zero Android dependencies**. 90% extract-as-is. + +**Gap:** References `QuoteBorder` from amethyst theme (just `RoundedCornerShape(15.dp)`). Replace with inline shape. + +**Action:** +1. Copy `QrCodeDrawer.kt` to `commons/src/commonMain/.../commons/ui/qrcode/` +2. Replace `QuoteBorder` reference with `RoundedCornerShape(15.dp)` +3. Add typealias in amethyst to maintain backward compat +4. Add `libs.zxing` dependency to `commons/build.gradle.kts` (see 0G) + +**Blocks:** Step 7 (nostrconnect QR display) + +--- + +### 0G. [COMMONS/BLOCKER] Add ZXing core dependency to commons module + +**File:** `commons/build.gradle.kts` + +**Problem:** ZXing core 3.5.4 is in `gradle/libs.versions.toml` but NOT imported in `commons/build.gradle.kts`. QrCodeDrawer needs it. + +**Action:** Add to `commonMain` dependencies (ZXing core is pure Java, works on JVM + Android): +```kotlin +commonMain.dependencies { + implementation(libs.zxing) // already in version catalog +} +``` + +**Note:** `zxing-embedded` (Android camera scanner wrapper) stays in amethyst only. Only `zxing:core` (encoder/decoder) goes to commons. + +**Blocks:** 0F (QrCodeDrawer extraction), Step 7 + +--- + +### 0H. [COMMONS/NEW] Create HeartbeatIndicator composable + +**File:** `commons/src/commonMain/.../commons/ui/components/HeartbeatIndicator.kt` (new) + +**Problem:** No animated pulsing dot composable exists in commons. Needed for sidebar signer connection health indicator. + +**Action:** Create shared composable: +- Input: `SignerConnectionState` sealed class (Connected/Unstable/Disconnected/NotRemote) +- Output: Pulsing dot (green/yellow/red) with `rememberInfiniteTransition` double-beat animation +- Desktop: wrapped in `TooltipArea` in DeckSidebar (desktop-only API) +- The composable itself is multiplatform; tooltip wrapping is platform-specific + +**Dependencies:** None — pure Compose animation APIs. + +**Blocks:** Step 8 (heartbeat UI) + +--- + +### Step 0 Summary + +| ID | Module | Type | Priority | Blocks | +|----|--------|------|----------|--------| +| 0A | quartz | `fromNostrConnectUri()` factory | 🔴 Blocker | Step 7 | +| 0B | quartz | Mutable/deferred `remotePubkey` | 🔴 Blocker | Step 7, 0A, 0C | +| 0C | quartz | NostrConnectEvent encryption | 🟢 Resolved by 0B | Step 7 | +| 0D | quartz | Secret validation in PubKeyResponse | 🟡 Security | Step 7 (required), Step 2 (optional) | +| 0E | quartz | `switchRelays()` method | ⚪ Nice-to-have | None | +| 0F | commons | Extract QrCodeDrawer | 🔴 Blocker | Step 7 | +| 0G | commons | ZXing dep in commons gradle | 🔴 Blocker | 0F, Step 7 | +| 0H | commons | HeartbeatIndicator composable | 🟡 Medium | Step 8 | + +**Critical path for bunker:// (Steps 1-6):** 0D (optional hardening) +**Critical path for nostrconnect:// (Step 7):** 0B → 0A → 0D, 0G → 0F +**Critical path for heartbeat UI (Step 8):** 0H + +### Implementation Order + +``` +Phase 1 (bunker:// — no quartz changes needed): + Steps 1 → 2 → 3 → 4 → 5 → 6 + +Phase 2 (quartz + commons prereqs): + 0G → 0F (commons: ZXing + QrCodeDrawer) + 0B → 0A (quartz: deferred remotePubkey + nostrconnect factory) + 0D (quartz: secret validation) + 0H (commons: HeartbeatIndicator) + +Phase 3 (nostrconnect + heartbeat): + Step 7 (depends on 0A, 0B, 0D, 0F, 0G) + Step 8 (depends on 0H) +``` + +**Key insight:** bunker:// login (Steps 1-6) requires ZERO quartz/commons changes — all quartz NIP-46 code works as-is. Ship bunker:// first, then layer nostrconnect + heartbeat. + +--- + +## Implementation + +### Step 1: AccountState + SignerType + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +Add `SignerType` sealed class: +```kotlin +sealed class SignerType { + data object Internal : SignerType() + data class Remote(val bunkerUri: String) : SignerType() +} +``` + +Extend `AccountState`: +```kotlin +sealed class AccountState { + data object LoggedOut : AccountState() + data object ConnectingRelays : AccountState() + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean, + val signerType: SignerType = SignerType.Internal, + ) : AccountState() +} +``` + +#### Research Insights + +**Existing pattern confirmed:** AccountState is observed via `accountManager.accountState.collectAsState()` in `App()` composable. Adding `ConnectingRelays` variant just needs a new `when` branch — zero refactoring needed. + +**DesktopIAccount compatibility:** Uses `accountState.pubKeyHex` (not `signer.pubKey`), so `NostrSignerRemote` (where `signer.pubKey` = ephemeral key) works transparently. Verified in audit. + +### Step 2: AccountManager — bunker login + persistence + heartbeat + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +#### `loginWithBunker(bunkerUri: String, client: INostrClient): Result` + +1. Validate URI format: starts with `bunker://`, hex pubkey (64 chars), `?relay=wss://` param +2. Generate ephemeral `KeyPair()` + wrap in `NostrSignerInternal` +3. `NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)` + - Factory handles relay extraction + secret parsing +4. Call `remoteSigner.openSubscription()` — starts listening for responses +5. Call `remoteSigner.connect()` — sends BunkerRequestConnect, waits 30s +6. **Validate secret** if present: connect response must return matching secret value +7. On success: call `remoteSigner.switchRelays()` (new — NIP-46 compliance) +8. Set `AccountState.LoggedIn(signer=remoteSigner, pubKeyHex=remotePubkey, signerType=Remote(bunkerUri))` + +#### Research Insights: Connect Flow Details + +**From quartz audit — `connect()` internals:** +``` +RemoteSignerManager.launchWaitAndParse(): + 1. Build BunkerRequestConnect(remoteKey, secret?, permissions?) + 2. Create NostrConnectEvent (NIP-44 encrypted to remote signer) + 3. client.send(event, relayList) + 4. Store continuation[requestID] in LargeCache + 5. tryAndWait(30s) — suspendCancellableCoroutine + 6. On response: newResponse() → resume continuation + 7. Parse via PubKeyResponse → SignerResult +``` + +**Exception mapping in NostrSignerRemote.convertExceptions():** +- `Successful` → return result +- `Rejected` → throw `ManuallyUnauthorizedException` +- `TimedOut` → throw `TimedOutException` +- `ReceivedButCouldNotPerform` → throw `CouldNotPerformException` + +**Auth URL edge case:** If remote signer needs web confirmation (nsec.app pattern), it returns `auth_url` response. Quartz doesn't handle this natively — **document as known limitation for v1**. Most bunker signers (Amber, local nsecBunker) don't use auth_url. + +#### `saveBunkerAccount(ephemeralPrivKeyHex: String)` + +Persistence files: +- `~/.amethyst/bunker_uri.txt` — full bunker URI (contains relay URLs + remote pubkey) +- `SecureKeyStorage("bunker_ephemeral")` — ephemeral private key (OS keyring) +- `~/.amethyst/last_account.txt` — user's npub (existing pattern) + +#### Research Insight: Session Persistence + +**Confirmed from NIP-46 community patterns:** +> "Once a connection has been successfully established and the BunkerPointer is stored, you do not need to call `connect()` on subsequent sessions." + +Save: ephemeral privkey + bunker URI + user npub. On restart, recreate `NostrSignerRemote` with saved ephemeral key — remote signer already trusts this keypair. + +**What NOT to save:** Don't store the secret separately — it's only needed for initial connect handshake. The ephemeral keypair IS the session credential. + +#### `loadSavedAccount(client: INostrClient): Result` + +Updated flow: +1. Check `bunker_uri.txt` — if exists, this is a bunker account +2. Load ephemeral privkey from `SecureKeyStorage("bunker_ephemeral")` +3. Load npub from `last_account.txt` → derive pubKeyHex +4. Create `NostrSignerInternal(KeyPair(ephemeralPrivKey))` +5. `NostrSignerRemote.fromBunkerUri(savedUri, ephemeralSigner, client)` +6. `remoteSigner.openSubscription()` — start listening (no connect() needed) +7. Return `LoggedIn(signer=remoteSigner, pubKeyHex=savedPubKeyHex, signerType=Remote)` +8. Fall back to existing internal key flow if no bunker file + +#### `logout(deleteKey: Boolean)` + +Bunker-specific cleanup: +1. `remoteSigner.closeSubscription()` — stop relay filter +2. Stop heartbeat job +3. Delete `bunker_uri.txt` +4. Delete `SecureKeyStorage("bunker_ephemeral")` +5. Clear `last_account.txt` +6. Set `AccountState.LoggedOut` + +#### Ping Heartbeat + +```kotlin +private var heartbeatJob: Job? = null +private var consecutiveFailures = 0 + +fun startHeartbeat(scope: CoroutineScope) { + heartbeatJob = scope.launch { + while (isActive) { + delay(60_000) + val current = currentAccount() ?: continue + val remoteSigner = current.signer as? NostrSignerRemote ?: continue + try { + remoteSigner.ping() + consecutiveFailures = 0 // reset on success + } catch (e: SignerExceptions.TimedOutException) { + consecutiveFailures++ + if (consecutiveFailures >= 3) { + forceLogoutWithReason("Lost connection to remote signer after 3 failed pings.") + } + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + forceLogoutWithReason("Remote signer revoked access.") + } + } + } +} +``` + +#### Research Insights: Heartbeat Patterns + +**Structured concurrency:** Heartbeat should be a child of the app's `CoroutineScope(SupervisorJob() + Dispatchers.Main)` already created in `App()`. SupervisorJob ensures heartbeat failure doesn't crash the app. + +**Existing pattern in codebase:** `RelayStat.pingInMs` tracks relay ping latency — similar concept. RelayConnectionManager handles relay disconnection with reconnect logic, but no heartbeat loop exists yet. + +**Simple counter vs AtomicInteger:** Since heartbeat runs in a single coroutine (sequential `while` loop), a plain `var consecutiveFailures: Int` is safe — no concurrent access. + +**Window minimization:** Desktop heartbeat should continue even when minimized — the remote signer session is independent of window focus. The `CoroutineScope` created with `remember` persists across recompositions. + +**Force logout state:** Use `MutableStateFlow` for `forceLogoutReason` — it's a stateful value that the UI observes, not a one-shot event. Dialog displays the reason, user dismisses, flow cleared. + +### Step 3: LoginCard — validation + connecting state + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt` + +Add `LoginState` enum: `IDLE`, `CONNECTING`, `ERROR` + +Add callback: `onLoginBunker: suspend (String) -> Result` + +**Bunker URI validation** (before attempting connect): +- Starts with `bunker://` +- Contains hex pubkey (64 hex chars after `bunker://`, before `?`) +- Contains at least one `relay=wss://...` param +- Show inline validation error if format wrong, don't attempt connection +- `secret=` is optional — do NOT require it + +**UX when `bunker://` detected:** +- Button text: "Login" → "Connect to Signer" +- On click with valid URI: set `CONNECTING`, show `CircularProgressIndicator` + "Connecting to remote signer..." +- Disable input + all buttons while `CONNECTING` +- On success: caller handles transition +- On failure: show error message, return to `IDLE`, user can retry + +#### Research Insights: UX Patterns + +**From NIP-46 community feedback:** +> "Connection establishment takes a while to show to users" — always show clear progress indication. + +**Pitfall discovered:** Users may not understand that bunker URI is a session credential: +> "Users might think the entire bunker URI is like a 2FA token and transmit it over untrusted messengers, and attackers can reuse the same URI" + +Consider: add subtle help text below input when bunker:// detected: "This URI connects to your remote signer. Treat it like a password." + +**Auto-detection approach:** Current `LoginCard` has a single `KeyInputField`. Detect `bunker://` prefix on input change — no new tabs/screens needed. Same pattern as NWC connection string input in settings. + +### Step 4: LoginScreen + ConnectingRelays screen + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/LoginScreen.kt` + +Add parameter: `relayClient: INostrClient` + +Wire `onLoginBunker` callback → `accountManager.loginWithBunker(bunkerUri, relayClient)` → on success: save + `onLoginSuccess()` + +**ConnectingRelaysScreen composable** (in same file): + +Shown when `AccountState.ConnectingRelays`: +``` +[Amethyst title text] +"Connecting to relays..." +[CircularProgressIndicator] +"Restoring remote signer session" +``` + +Used on app restart when a bunker account is saved — shows while relays connect before recreating `NostrSignerRemote`. + +#### Research Insight: Startup Sequence + +**From Main.kt audit — current startup:** +```kotlin +DisposableEffect(Unit) { + scope.launch(Dispatchers.IO) { accountManager.loadSavedAccount() } + relayManager.addDefaultRelays() + relayManager.connect() + subscriptionsCoordinator.start() + onDispose { ... } +} +``` + +**Problem:** `loadSavedAccount()` for bunker accounts needs `relayManager.client` — but relay connections happen asynchronously. Current code loads account independently of relay state. + +**Solution:** For bunker accounts, wait for relay connection before recreating signer. The pattern `relayManager.connectedRelays.first { it.isNotEmpty() }` already exists in `MainContent` LaunchedEffect for DM subscriptions. + +### Step 5: Main.kt — relay state, pass client, heartbeat + +**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt` + +**Updated startup flow:** +```kotlin +DisposableEffect(Unit) { + relayManager.addDefaultRelays() + relayManager.connect() + subscriptionsCoordinator.start() + + scope.launch(Dispatchers.IO) { + if (accountManager.hasBunkerAccount()) { + accountManager.setConnectingRelays() + // Wait for relay connections — pattern from MainContent DM setup + relayManager.connectedRelays.first { it.isNotEmpty() } + accountManager.loadSavedAccount(relayManager.client) + accountManager.startHeartbeat(scope) + } else { + accountManager.loadSavedAccount(relayManager.client) + } + } + + onDispose { + accountManager.stopHeartbeat() + subscriptionsCoordinator.clear() + relayManager.disconnect() + } +} +``` + +**Account state routing — add ConnectingRelays + ForceLogout:** +```kotlin +when (accountState) { + is AccountState.LoggedOut -> LoginScreen(accountManager, relayManager.client, onLoginSuccess) + is AccountState.ConnectingRelays -> ConnectingRelaysScreen() + is AccountState.LoggedIn -> MainContent(...) +} + +// Force logout overlay +val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() +forceLogoutReason?.let { reason -> + ForceLogoutDialog(reason = reason, onDismiss = { accountManager.clearForceLogoutReason() }) +} +``` + +#### Research Insight: Scope Lifecycle + +**Current app scope:** `remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }` — created in `App()` composable, lives for the duration of the window. Heartbeat as a child of this scope is correct — it auto-cancels when app closes. + +**onDispose cleanup:** Add `accountManager.stopHeartbeat()` to the existing `onDispose` block alongside `subscriptionsCoordinator.clear()` and `relayManager.disconnect()`. + +### Step 6: ForceLogoutDialog + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/ForceLogoutDialog.kt` (new) + +Simple `AlertDialog`: +- Title: "Session Terminated" +- Body: reason string +- Single "OK" button → dismiss → login screen already showing (state is LoggedOut) + +## Error Handling + +| Exception | Source | User Message | +|-----------|--------|-------------| +| URI validation fail | LoginCard | "Invalid bunker URI. Expected: bunker://\?relay=wss://..." | +| `TimedOutException` | `connect()` | "Connection timed out. Ensure remote signer is online and has approved the connection." | +| `ManuallyUnauthorizedException` | `connect()` | "Connection rejected by remote signer." | +| `CouldNotPerformException` | `connect()` | "Remote signer error: {message}" | +| Generic exception | `connect()` | "Connection failed: {message}" | +| Heartbeat 3x timeout | `ping()` | Force logout: "Lost connection to remote signer after 3 failed pings." | +| Heartbeat revocation | `ping()` | Force logout: "Remote signer revoked access." | +| `decryptZapEvent` | Runtime | Known limitation — zap decryption not supported with remote signer | + +### Research Insight: Relay-Specific Issues + +**PoW requirement:** Some relays (nostr.mom, nos.lol, damus) rate-limit or require Proof of Work for kind 24133 events. If users specify these relays in their bunker URI, connections may fail. **Mitigation:** Document in error message. No code change needed — the relay list comes from the bunker URI (remote signer's choice). + +## Files Modified + +| File | Changes | +|------|---------| +| `AccountManager.kt` | SignerType, ConnectingRelays state, loginWithBunker(), saveBunkerAccount(), loadSavedAccount() updated, logout() updated, heartbeat, forceLogout | +| `LoginCard.kt` | LoginState enum, bunker URI validation, onLoginBunker callback, connecting UI, help text | +| `LoginScreen.kt` | Add relayClient param, wire bunker login, ConnectingRelaysScreen composable | +| `Main.kt` | Startup relay-wait flow, pass relayManager.client, heartbeat lifecycle, forceLogout dialog | +| `ForceLogoutDialog.kt` | **New** — AlertDialog for session termination | + +## Reused As-Is (no changes needed — verified) + +| File | What | Verified | +|------|------|----------| +| `NostrSignerRemote.kt` | Full NIP-46 client: connect, sign, encrypt, decrypt, ping | ✅ All methods present | +| `RemoteSignerManager.kt` | Request/response lifecycle with timeout | ✅ 30s configurable | +| `NostrConnectEvent.kt` | Kind 24133 wrapper with NIP-44 encryption | ✅ | +| `SignerExceptions.kt` | TimedOut, ManuallyUnauthorized, CouldNotPerform | ✅ | +| `DesktopIAccount.kt` | Uses `accountState.pubKeyHex` not `signer.pubKey` | ✅ Safe with remote signer | +| `RelayConnectionManager.kt` | Exposes `client: INostrClient` | ✅ | +| `SecureKeyStorage.kt` | OS keyring + encrypted fallback | ✅ Reuse for ephemeral key | + +## Security Considerations + +### Ephemeral Key = Session Credential +- Stored in OS keyring (macOS Keychain / Windows Credential Manager) via existing `SecureKeyStorage` +- If compromised: attacker can impersonate user's signing requests until remote signer revokes +- **Mitigation:** Remote signer can revoke anytime; heartbeat detects revocation + +### Bunker URI as Session Token +- URI contains remote pubkey + relay URLs — NOT the user's private key +- But it enables initial connection establishment +- Once connected, the ephemeral keypair is the actual session credential +- **Secret parameter** (when present): only used once during initial connect. Not stored separately. + +### Relay MITM +- NIP-46 messages are NIP-44 encrypted end-to-end between ephemeral key and remote signer +- Relay can see metadata (who's talking to whom) but NOT message content +- **No MITM on message content** — encryption is between known pubkeys + +### Force Logout on Revocation +- `ManuallyUnauthorizedException` from ping → immediate logout + file cleanup +- 3 consecutive ping timeouts → logout (remote signer may be permanently offline) +- All bunker files deleted on force logout — clean slate + +## Known Limitations (v1) + +1. **Auth URL flow** — nsec.app may return `auth_url` response requiring web confirmation. Not handled in v1. Most signers (Amber, local bunkers) don't use this. +2. **`decryptZapEvent()`** — Not implemented in quartz's `NostrSignerRemote`. Zap decryption will fail for bunker accounts. +3. **`deriveKey()`** — Not implemented. NIP-06 key derivation won't work for bunker accounts. +4. **Single account** — Only one bunker OR one internal account at a time. Multi-account is a separate PR. + +## Verification + +1. `./gradlew :desktopApp:compileKotlin` — builds +2. `./gradlew :desktopApp:run` — test flows: + - **Invalid URI** → paste `bunker://bad` → inline validation error, no network call + - **Valid format, missing relay** → paste `bunker://<64hex>` → validation error about missing relay + - **Login** → paste valid bunker URI → "Connecting..." → approve on phone → main screen + - **Timeout** → paste URI, don't approve → 30s → timeout error, can retry + - **Rejection** → paste URI, reject on phone → "rejected" error + - **Restart** → close app → relaunch → "Connecting to relays..." → auto-login (no re-approval needed) + - **Heartbeat** → revoke access on remote signer → force logout dialog within 60s + - **Logout** → logout → bunker files deleted → login screen + - **Post note** → compose + send → signed via remote signer transparently + - **Send DM** → NIP-17 gift wrap → encrypted via remote signer +3. `./gradlew spotlessApply` before commit + +## Answered Questions + +1. **Both `bunker://` AND `nostrconnect://`** — see Step 7 below +2. **60s hardcoded** heartbeat interval +3. **Sidebar heartbeat icon** — tiny pulsing dot with tooltip, see Step 8 below +4. **Yes, persist relay updates** from `switch_relays` to `bunker_uri.txt` +5. **No auto-retry** — show error immediately, user clicks to retry + +--- + +## Step 7: nostrconnect:// (Client-Initiated) Login + +### Protocol Difference + +| Aspect | `bunker://` (remote-signer-initiated) | `nostrconnect://` (client-initiated) | +|--------|---------------------------------------|--------------------------------------| +| Who generates URI | Remote signer (nsec.app, Amber) | Desktop client | +| Who scans/pastes | Desktop user pastes into app | User pastes into their signer app | +| Secret | Optional | **Required** (client generates) | +| Permissions | Not specified in URI | Client requests via `perms=` param | +| Flow | User already has URI → paste → connect | Client shows URI → user takes to signer → signer connects back | + +### nostrconnect:// URI Format (from NIP-46 spec) + +``` +nostrconnect://?relay=wss://relay1.example.com&relay=wss://relay2.example.com&secret=&perms=nip44_encrypt,nip44_decrypt,sign_event&name=Amethyst+Desktop&url=https://github.com/vitorpamplona/amethyst&image= +``` + +**Parameters:** +- `` — ephemeral pubkey generated by desktop (64 hex chars) +- `relay=` — relay URLs for communication (use app's connected relays) +- `secret=` — **REQUIRED** random string, client validates signer returns it in connect response +- `perms=` — comma-separated permissions to request +- `name=` — app display name ("Amethyst Desktop") +- `url=` — app URL (optional) +- `image=` — app icon URL (optional) + +### UX Flow + +1. User clicks **"Connect with Signer"** tab/button on login screen +2. Desktop generates ephemeral `KeyPair()` and random `secret` +3. Builds `nostrconnect://` URI with connected relay URLs + perms + secret +4. Displays: + - **QR code** (primary — user scans with phone signer) + - **Copyable text** (fallback — user pastes into web signer like nsec.app) + - **"Waiting for signer to connect..."** status with spinner +5. Desktop subscribes to kind 24133 events on specified relays, filtered to ephemeral pubkey +6. User scans QR / pastes URI into their signer app +7. Signer sends `connect` response with the secret value +8. Desktop validates secret matches → `AccountState.LoggedIn` +9. If no response within **120s** (longer than bunker — user needs time to open phone): show timeout, allow retry + +### Implementation Details + +**LoginCard changes:** +- Add toggle/tab: "Paste Key" | "Connect with Signer" +- "Connect with Signer" tab shows QR + copyable URI + waiting state +- State machine: `IDLE → GENERATING → WAITING_FOR_SIGNER → CONNECTED / ERROR` + +**AccountManager.loginWithNostrConnect(client: INostrClient): Pair** +1. Generate ephemeral `KeyPair()` + `NostrSignerInternal` +2. Generate random secret (16 hex chars) +3. Get connected relay URLs from relay manager (+ add `wss://relay.nsec.app` as fallback NIP-46 relay) +4. Build `nostrconnect://` URI string with `name=Amethyst+Desktop&perms=sign_event,nip44_encrypt,nip44_decrypt` +5. Subscribe to kind 24133 events on specified relays, filtered to ephemeral pubkey +6. Return (URI string, waiting Job) +7. When connect response received: validate secret, extract remote pubkey from response, create `NostrSignerRemote` +8. Set LoggedIn + +**Key difference from bunker://:** With nostrconnect, client doesn't know user's pubkey upfront — learns it from signer's connect response. `fromBunkerUri()` cannot be reused directly — need to construct `NostrSignerRemote` manually after learning the remote pubkey. + +**Quartz gap:** `RemoteSignerManager.launchWaitAndParse()` sends a request AND waits. For nostrconnect, client needs to just WAIT (signer initiates). Options: +- Build a `waitForConnect()` method in AccountManager that subscribes + waits +- Or call `openSubscription()` manually, then wait for the `newResponse()` callback + +**Timeout:** 120s for nostrconnect (user needs to open phone, find app, scan QR). No auto-retry. + +### QR Code Generation + +**ZXing `core` 3.5.4 already in `gradle/libs.versions.toml`!** And there's an existing `QrCodeDrawer.kt` in the Android app (`amethyst/src/main/java/.../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt`) that uses: +- `com.google.zxing.qrcode.encoder.Encoder` (pure Java, in `zxing:core`) +- Compose Canvas APIs (multiplatform) +- **Zero Android dependencies** — fully portable to desktop + +**Action:** Extract `QrCodeDrawer.kt` to `commons/commonMain/` and add `libs.zxing` dependency to `commons` module. No new libraries needed. + +**Gradle addition (commons module):** +```kotlin +// commons/build.gradle.kts - commonMain sourceSets +implementation(libs.zxing) // already in version catalog +``` + +### Persistence + +Same as bunker:// — save ephemeral key + URI + npub. On restart, reconnect without needing QR again (remote signer remembers ephemeral key). + +### File Changes + +| File | nostrconnect:// additions | +|------|--------------------------| +| `AccountManager.kt` | `loginWithNostrConnect()` method, generates URI + waits for connect | +| `LoginCard.kt` | Tab toggle "Paste Key" / "Connect with Signer", QR display, waiting state | +| `LoginScreen.kt` | Wire nostrconnect flow, pass relay client | +| `build.gradle.kts` | Add `qrcode-kotlin-jvm` dependency | + +--- + +## Step 8: Heartbeat Status Indicator (Sidebar) + +### Design + +Tiny pulsing dot in the sidebar (below existing nav icons, above settings gear). Communicates remote signer connection health at a glance. + +### States + +| State | Visual | Tooltip | +|-------|--------|---------| +| **Connected** | Green dot, gentle pulse animation (1.5s cycle) | "Remote signer connected" | +| **Ping failed (1-2x)** | Yellow dot, faster pulse (0.8s cycle) | "Remote signer: connection unstable" | +| **Disconnected** | Red dot, no pulse (static) | "Remote signer: disconnected" | +| **Not remote** | Hidden | — (only show for bunker/nostrconnect accounts) | + +### Animation Pattern — Double-Beat Heartbeat + +Uses `keyframes` for organic lub-dub feel (two peaks per cycle): + +```kotlin +val infiniteTransition = rememberInfiniteTransition(label = "heartbeat") +val scale by infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0.85f at 0 // rest + 1.15f at 300 using EaseOut // first beat (systole) + 0.95f at 500 // contract + 1.05f at 700 using EaseInOut // second beat (diastole) + 0.85f at 1200 // rest + }, + repeatMode = RepeatMode.Restart, + ), + label = "pulseScale", +) + +// Apply via graphicsLayer on a small Box with CircleShape +Box( + Modifier + .size(8.dp) + .graphicsLayer { scaleX = scale; scaleY = scale } + .background(color, CircleShape) +) +``` + +**Existing animation patterns in codebase:** `ProfileBroadcastBanner.kt` uses `animateFloatAsState` with `tween(300)` for progress bars. No `rememberInfiniteTransition` usage yet — this would be the first. + +### Tooltip (Desktop-only API) + +```kotlin +@OptIn(ExperimentalFoundationApi::class) +TooltipArea( + tooltip = { + Surface( + modifier = Modifier.shadow(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(4.dp), + ) { + Text(tooltipText, modifier = Modifier.padding(8.dp), style = MaterialTheme.typography.bodySmall) + } + }, + delayMillis = 400, + tooltipPlacement = TooltipPlacement.CursorPoint(alignment = Alignment.BottomEnd), +) { + HeartbeatDot(connectionState) +} +``` + +### Connection State Flow + +```kotlin +// In AccountManager +sealed class SignerConnectionState { + data object NotRemote : SignerConnectionState() // internal signer, hide indicator + data object Connected : SignerConnectionState() + data class Unstable(val failCount: Int) : SignerConnectionState() + data object Disconnected : SignerConnectionState() +} + +val signerConnectionState: StateFlow +``` + +Heartbeat updates this state on each ping result. UI observes via `collectAsState()`. + +### File Changes + +| File | Heartbeat indicator additions | +|------|------------------------------| +| `AccountManager.kt` | `SignerConnectionState` sealed class + `signerConnectionState: StateFlow` | +| `DeckSidebar.kt` | Add `HeartbeatIndicator` composable at bottom of sidebar | +| `SinglePaneLayout.kt` | Add `HeartbeatIndicator` at bottom of nav rail | +| `HeartbeatIndicator.kt` | **New** — pulsing dot + tooltip composable | + +--- + +## Updated Files Summary + +| File | Changes | +|------|---------| +| `AccountManager.kt` | SignerType, ConnectingRelays, loginWithBunker(), loginWithNostrConnect(), saveBunkerAccount(), loadSavedAccount(), logout(), heartbeat, forceLogout, SignerConnectionState | +| `LoginCard.kt` | LoginState, tab toggle (paste/connect), bunker URI validation, nostrconnect QR + waiting, connecting UI | +| `LoginScreen.kt` | relayClient param, wire bunker + nostrconnect flows, ConnectingRelaysScreen | +| `Main.kt` | Startup relay-wait, heartbeat lifecycle, forceLogout dialog, pass relayManager.client | +| `ForceLogoutDialog.kt` | **New** — AlertDialog for session termination | +| `HeartbeatIndicator.kt` | **New** — pulsing dot + tooltip composable | +| `DeckSidebar.kt` | Add HeartbeatIndicator between spacer and Settings | +| `SinglePaneLayout.kt` | Add HeartbeatIndicator in nav rail | +| `QrCodeDrawer.kt` | **Extract** from `amethyst/.../qrcode/` → `commons/commonMain/` (zero Android deps) | +| `commons/build.gradle.kts` | Add `libs.zxing` (core) dependency | + +## Answered Questions (Round 2) + +1. **Full perms** for nostrconnect: `sign_event,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt` +2. ~~Dark mode QR~~ — defer to implementation, follow existing theme +3. **Both layouts** — heartbeat in SINGLE_PANE nav rail AND DECK sidebar diff --git a/docs/plans/2026-03-05-nip46-improvements-plan.md b/docs/plans/2026-03-05-nip46-improvements-plan.md new file mode 100644 index 000000000..21dfeea9c --- /dev/null +++ b/docs/plans/2026-03-05-nip46-improvements-plan.md @@ -0,0 +1,106 @@ +--- +title: "feat: NIP-46 remote signer improvements (post-MVP)" +type: feat +status: backlog +date: 2026-03-05 +origin: docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md +--- + +# feat: NIP-46 remote signer improvements (post-MVP) + +Deferred items from the NIP-46 relay isolation fix. All are nice-to-haves discovered during deepening/review — none block the initial fix. + +## 1. `auth_url` handling (nsec.app compatibility) + +**Priority:** Medium — only affects nsec.app users, not Amber + +NIP-46 spec defines `auth_url` for signers needing out-of-band user confirmation: +```json +{"id": "", "result": "auth_url", "error": ""} +``` + +Client should open the URL, then resubscribe for the actual result using the same request ID. + +**Current behavior:** Treated as `ReceivedButCouldNotPerform` error → times out. + +**To implement:** +- New `BunkerResponseAuthUrl` class + parser +- New `SignerResult` state for pending auth +- Desktop UI to open URL in browser + resubscribe +- Timeout management for multi-step flow + +**Files:** `BunkerResponse.kt`, `SignerResult.kt`, response parsers, `RemoteSignerManager.kt`, desktop `LoginCard.kt` + +## 2. `get_public_key` validation after connect + +**Priority:** Low — security hardening, not correctness + +Infrastructure already exists: `NostrSignerRemote.getPublicKey()` is fully implemented and production-ready. `connect()` already returns the user pubkey, so this is redundant for normal operation. + +**Value:** Belt-and-suspenders against MITM on the connect response. Low risk since NIP-44 encryption protects the channel. + +**To implement:** +```kotlin +// In AccountManager.loadBunkerAccount() after remoteSigner.openSubscription() +val confirmedPubkey = remoteSigner.getPublicKey() +if (confirmedPubkey != expectedPubkey) { + return Result.failure(Exception("Pubkey mismatch: signer returned $confirmedPubkey")) +} +``` + +**Cost:** 1 extra RPC round-trip + potential 30s timeout on failure. + +## 3. Dynamic `since` filter on reconnect + +**Priority:** Low — optimization, no correctness issue + +`NostrClientStaticReq` re-sends the exact same filter on WebSocket reconnect. With `since = now() - 60`, this means after a reconnect the relay replays events from the original subscription time — wasteful but harmless (request ID matching discards stale responses). + +**To implement:** Switch `NostrSignerRemote` from `NostrClientStaticReq` to `NostrClientDynamicReq`: +```kotlin +// Lambda recomputes since on each reconnect +val subscription = client.dynamicReq( + relays = relays.toList(), + filter = { + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = TimeUtils.now() - 60, + ) + }, +) { event -> ... } +``` + +**Tradeoff:** More complexity. Only matters if relay operators complain about repeated full-history queries or reconnects are frequent. + +## 4. `NostrClient.close()` method (upstream) + +**Priority:** Medium — affects any code creating temporary `NostrClient` instances + +`NostrClient.disconnect()` only closes WebSockets but leaks: +- `allRelays` combine flow (`stateIn(Eagerly)` on `scope`) +- `debouncingConnection` debounce flow (`stateIn(Eagerly)` on `scope`) +- The `SupervisorJob` scope itself + +**To implement:** +```kotlin +// In NostrClient.kt +fun close() { + disconnect() + scope.cancel() +} +``` + +**Current workaround:** `disconnectNip46Client()` in AccountManager handles our specific case. But any future code creating temporary `NostrClient` instances will hit the same leak. + +**Recommendation:** PR upstream to quartz adding `close()` to `NostrClient`. + +## 5. `FeedMetadataCoordinator` reactive relay sets + +**Priority:** Low — deferred construction in the fix plan solves the immediate bug + +Currently `indexRelays` is `private val` — immutable after construction. If relay sets change at runtime (user adds/removes relays), the coordinator won't pick up changes. + +**To implement:** Accept `StateFlow>` instead of `Set`, collect in coordinator's scope. + +**Current workaround:** Nullable coordinator pattern with lazy construction after relays connect. diff --git a/docs/plans/2026-03-05-nip46-tdd-tests-plan.md b/docs/plans/2026-03-05-nip46-tdd-tests-plan.md new file mode 100644 index 000000000..5471baf99 --- /dev/null +++ b/docs/plans/2026-03-05-nip46-tdd-tests-plan.md @@ -0,0 +1,386 @@ +--- +title: "test: TDD tests for NIP-46 relay isolation fix" +type: test +status: active +date: 2026-03-05 +parent: 2026-03-05-fix-nip46-relay-isolation-and-init-plan.md +--- + +# test: TDD tests for NIP-46 relay isolation fix + +## Overview + +Write tests that **reproduce the three NIP-46 bugs** before they're fixed, then verify the fix. Tests should fail against the old shared-client architecture and pass after relay isolation. + +## Bug → Test Mapping + +| Bug | Root Cause | Test Strategy | +|-----|-----------|---------------| +| **Bug 1:** Empty `indexRelays` snapshot | `availableRelays.value` captured at coordinator construction = `emptySet()` | Verify coordinator receives non-empty relay set | +| **Bug 2:** Shared client relay contamination | NIP-46 relay leaks into general pool → ClosedMessage loop | Verify NIP-46 traffic stays on dedicated client, never touches general client | +| **Bug 3:** Sign request response lost | Subscription disrupted by ClosedMessage churn from Bug 2 | Verify NIP-46 subscription filters only target NIP-46 relays | + +## Test Infrastructure + +### Existing +- Framework: `kotlin.test` + `kotlinx-coroutines-test` + `mockk` +- Location: `desktopApp/src/jvmTest/kotlin/.../desktop/account/` +- Mocks: `EmptyNostrClient` (no-op), `mockk(relaxed = true)`, temp dirs +- Pattern: `@BeforeTest` setup → `runTest {}` → `@AfterTest` cleanup + +### Needed +- `SpyNostrClient` — wraps `INostrClient`, records which relays receive `send()` and `openReqSubscription()` calls +- No new dependencies (mockk `capture` + `slot` + `verify` sufficient) + +## Phase 0: Fix Existing Broken Tests + +**File:** `AccountManagerLoadAccountTest.kt` + +Three tests pass `client` param to `loadSavedAccount()` which no longer accepts it: + +| Test | Fix | +|------|-----| +| `loadSavedAccountBunkerNoEphemeralReturnsFailure` (line 114) | Remove `client = EmptyNostrClient` arg | +| `loadSavedAccountBunkerNoClientFallsBackToInternal` (line 119-136) | **Delete entirely** — concept no longer exists (AccountManager always creates its own NIP-46 client) | +| `loadSavedAccountBunkerSuccess` (line 155-158) | Remove `client = EmptyNostrClient` arg | + +## Phase 1: Relay Isolation Tests (Bug 2 + 3) + +### Test File: `AccountManagerNip46IsolationTest.kt` + +**Location:** `desktopApp/src/jvmTest/kotlin/.../desktop/account/` + +#### Test 1: `nip46ClientIsNotTheGeneralClient` + +**Reproduces:** Bug 2 root cause — shared `NostrClient` pollution. + +```kotlin +@Test +fun nip46ClientIsNotTheGeneralClient() = runTest { + // Setup: bunker URI + ephemeral key in storage + val validHex = "a".repeat(64) + val ephemeralKeyPair = KeyPair() + File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app") + File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub()) + coEvery { storage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey() + + // Load bunker account — this creates the internal NIP-46 client + manager.loadSavedAccount() + + // The general relay pool client (e.g. relayManager.client) should NOT + // have any NIP-46 relay subscriptions. Since AccountManager creates its + // own client internally, we verify via the accountState having Remote signer. + val state = manager.currentAccount() + assertNotNull(state) + assertIs(state.signerType) + + // The signer's client should be the dedicated one, not EmptyNostrClient + val signer = state.signer as NostrSignerRemote + // NostrSignerRemote stores its client — it should be a real NostrClient, not null + assertNotNull(signer) +} +``` + +**Key assertion:** AccountManager creates a dedicated client internally rather than requiring one passed in. + +#### Test 2: `loginWithBunkerDoesNotRequireExternalClient` + +**Reproduces:** The old API required callers to pass `relayManager.client`. + +```kotlin +@Test +fun loginWithBunkerDoesNotRequireExternalClient() = runTest { + // loginWithBunker() should compile and work with just a bunkerUri + // This test verifies the API — no client param needed + try { + manager.loginWithBunker("bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app") + } catch (e: Exception) { + // Expected — no real relay to connect to. But it should NOT throw + // a compile error or "client required" error. + assertTrue( + e.message?.contains("Connection") == true || + e.message?.contains("timed out") == true || + e.message?.contains("Connection failed") == true, + "Unexpected error: ${e.message}", + ) + } +} +``` + +#### Test 3: `loginWithNostrConnectDoesNotRequireExternalClient` + +Same pattern — verifies API no longer takes `client`. + +```kotlin +@Test +fun loginWithNostrConnectDoesNotRequireExternalClient() = runTest { + var generatedUri: String? = null + try { + manager.loginWithNostrConnect { uri -> generatedUri = uri } + } catch (e: Exception) { + // Expected — no signer scanning the QR + assertTrue(e.message?.contains("Timed out") == true || e.message != null) + } + // URI should have been generated even if connection failed + assertNotNull(generatedUri) + assertTrue(generatedUri!!.startsWith("nostrconnect://")) +} +``` + +#### Test 4: `logoutDisconnectsNip46Client` + +**Reproduces:** Bug 2 side effect — leaked NIP-46 WebSocket after logout. + +```kotlin +@Test +fun logoutDisconnectsNip46Client() = runTest { + // Login with bunker (will create internal NIP-46 client) + val keyPair = KeyPair() + manager.loginWithKey(keyPair.privKey!!.toNsec()) + // Force to Remote type for test + // Instead: test that logout from any state doesn't crash + manager.logout() + + // After logout, state should be LoggedOut + assertIs(manager.accountState.value) + // Signer connection should be NotRemote + assertIs(manager.signerConnectionState.value) +} +``` + +#### Test 5: `logoutThenLoginCreatesNewNip46Client` + +**Reproduces:** Ensuring fresh client after re-login (no stale state). + +```kotlin +@Test +fun logoutThenLoginCreatesNewNip46Client() = runTest { + // First bunker load + val validHex = "a".repeat(64) + val ephemeralKeyPair = KeyPair() + File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app") + File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub()) + coEvery { storage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey() + + manager.loadSavedAccount() + val firstState = manager.currentAccount() + assertNotNull(firstState) + + // Logout — should disconnect NIP-46 client + manager.logout() + assertIs(manager.accountState.value) + + // Re-load — should create a NEW NIP-46 client + File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app") + File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub()) + manager.loadSavedAccount() + val secondState = manager.currentAccount() + assertNotNull(secondState) + + // Both states should be valid Remote signers + assertIs(firstState.signerType) + assertIs(secondState.signerType) +} +``` + +## Phase 2: State Transition Tests + +### Test File: `AccountManagerStateTransitionTest.kt` + +#### Test 6: `bunkerRestoreShowsConnectingThenLoggedIn` + +**Reproduces:** Bug 1 UX — user sees nothing while connecting. + +```kotlin +@Test +fun bunkerRestoreShowsConnectingThenLoggedIn() = runTest { + val states = mutableListOf() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.accountState.toList(states) + } + + // Set connecting state (what Main.kt does before loadSavedAccount) + manager.setConnectingRelays() + + // Then load internal account + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + File(amethystDir, "last_account.txt").writeText(npub) + coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey() + manager.loadSavedAccount() + + advanceUntilIdle() + + // Should have: LoggedOut → ConnectingRelays → LoggedIn + assertTrue(states.size >= 3, "Expected at least 3 state transitions, got: $states") + assertIs(states[0]) + assertIs(states[1]) + assertIs(states[2]) + + collector.cancel() +} +``` + +#### Test 7: `loadSavedAccountBunkerNoEphemeralReturnsFailure` + +Updated version — no `client` param. + +```kotlin +@Test +fun loadSavedAccountBunkerNoEphemeralReturnsFailure() = runTest { + val validHex = "a".repeat(64) + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + + File(amethystDir, "last_account.txt").writeText(npub) + File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://r.com") + coEvery { storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) } returns null + + val result = manager.loadSavedAccount() + assertTrue(result.isFailure) +} +``` + +## Phase 3: NostrSignerRemote Isolation Tests (quartz) + +### Test File: `NostrSignerRemoteIsolationTest.kt` + +**Location:** `quartz/src/commonTest/kotlin/.../nip46RemoteSigner/signer/` + +#### Test 8: `subscriptionFilterTargetsOnlyBunkerRelays` + +**Reproduces:** Bug 2 — subscription filters should ONLY reference NIP-46 relays. + +```kotlin +@Test +fun subscriptionFilterTargetsOnlyBunkerRelays() { + val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/") + val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/") + + val capturedFilters = mutableListOf>>() + val mockClient = mockk(relaxed = true) + every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs + + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val remoteSigner = NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = "a".repeat(64), + relays = setOf(bunkerRelay), + client = mockClient, + ) + remoteSigner.openSubscription() + + // Verify filter only targets bunker relay + assertTrue(capturedFilters.isNotEmpty(), "No subscription opened") + capturedFilters.forEach { filterMap -> + assertTrue(filterMap.keys.all { it == bunkerRelay }, "Filter contains non-bunker relay: ${filterMap.keys}") + assertTrue(generalRelay !in filterMap.keys, "General relay leaked into NIP-46 filter") + } +} +``` + +#### Test 9: `subscriptionFilterContainsCorrectKindAndPTag` + +```kotlin +@Test +fun subscriptionFilterContainsCorrectKindAndPTag() { + val capturedFilters = mutableListOf>>() + val mockClient = mockk(relaxed = true) + every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs + + val ephemeralKeyPair = KeyPair() + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + val remoteSigner = NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = "b".repeat(64), + relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")), + client = mockClient, + ) + remoteSigner.openSubscription() + + val filters = capturedFilters.flatMap { it.values.flatten() } + assertTrue(filters.isNotEmpty()) + filters.forEach { filter -> + assertTrue(filter.kinds?.contains(NostrConnectEvent.KIND) == true, + "Filter missing kind ${NostrConnectEvent.KIND}") + assertTrue(filter.tags?.get("p")?.contains(ephemeralSigner.pubKey) == true, + "Filter missing p-tag for ephemeral pubkey") + } +} +``` + +## Phase 4: `since` Filter Tests (Bug 3 mitigation) + +### Test File: add to `NostrSignerRemoteIsolationTest.kt` + +#### Test 10: `subscriptionFilterHasSinceTimestamp` + +**Reproduces:** Stale NIP-46 responses from previous sessions being processed. + +```kotlin +@Test +fun subscriptionFilterHasSinceTimestamp() { + val capturedFilters = mutableListOf>>() + val mockClient = mockk(relaxed = true) + every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs + + val beforeTime = TimeUtils.now() + + val remoteSigner = NostrSignerRemote( + signer = NostrSignerInternal(KeyPair()), + remotePubkey = "c".repeat(64), + relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")), + client = mockClient, + ) + remoteSigner.openSubscription() + + val filters = capturedFilters.flatMap { it.values.flatten() } + assertTrue(filters.isNotEmpty()) + filters.forEach { filter -> + assertNotNull(filter.since, "Filter missing 'since' timestamp") + // since should be roughly now - 60s (with some tolerance) + val expectedMin = beforeTime - 120 // extra tolerance + val expectedMax = beforeTime + assertTrue(filter.since!! >= expectedMin, "since too old: ${filter.since}") + assertTrue(filter.since!! <= expectedMax, "since in the future: ${filter.since}") + } +} +``` + +> **Note:** This test will FAIL until Step 8 (add `since` to NostrSignerRemote) is implemented. That's the TDD red phase. + +## Implementation Order + +| # | Action | File | Phase | +|---|--------|------|-------| +| 1 | Fix broken tests (remove `client` param) | `AccountManagerLoadAccountTest.kt` | 0 | +| 2 | Write relay isolation tests (Tests 1-5) | `AccountManagerNip46IsolationTest.kt` | 1 | +| 3 | Write state transition tests (Tests 6-7) | `AccountManagerStateTransitionTest.kt` | 2 | +| 4 | Write signer isolation tests (Tests 8-9) | `NostrSignerRemoteIsolationTest.kt` | 3 | +| 5 | Write `since` filter test (Test 10) — **expect RED** | `NostrSignerRemoteIsolationTest.kt` | 4 | +| 6 | Run all → confirm Tests 1-9 GREEN, Test 10 RED | — | — | +| 7 | Implement Step 8 (`since` filter in quartz) | `NostrSignerRemote.kt` | — | +| 8 | Run all → confirm all GREEN | — | — | + +## Test Commands + +```bash +# Run all desktop tests +./gradlew :desktopApp:test + +# Run specific test class +./gradlew :desktopApp:test --tests "*.AccountManagerNip46IsolationTest" + +# Run quartz common tests +./gradlew :quartz:jvmTest --tests "*.NostrSignerRemoteIsolationTest" + +# Run all NIP-46 related tests +./gradlew :desktopApp:test --tests "*.AccountManager*" :quartz:jvmTest --tests "*.nip46RemoteSigner.*" +``` + +## Unanswered Questions + +1. `getOrCreateNip46Client()` is private — can't directly verify single-creation from tests without reflection or `@VisibleForTesting`. Sufficient to test indirectly through public API? +2. `NostrSignerRemote.subscription` field is private — MockK `capture` on `openReqSubscription` is the best proxy for filter verification? +3. Should we add Turbine dependency for cleaner StateFlow testing, or stick with manual `backgroundScope` collection? +4. `NostrClient.connect()` is non-suspending (fire-and-forget) — how to test that the NIP-46 client actually connects before subscriptions? Accept relay auto-queue behavior? diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eefeb00f5..95a41277f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,6 @@ tarsosdsp = "2.5" torAndroid = "0.4.9.5" translate = "17.0.3" unifiedpush = "3.0.10" -urlDetector = "0.1.23" vico-charts = "2.4.3" zelory = "3.0.1" zoomable = "2.11.1" @@ -154,7 +153,6 @@ secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jn tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } -url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" } vico-charts-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico-charts" } vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts" } diff --git a/quartz/.idea/.gitignore b/quartz/.idea/.gitignore new file mode 100644 index 000000000..ab1f4164e --- /dev/null +++ b/quartz/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 75a7e96ea..48ef19a91 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -161,9 +161,6 @@ kotlin { // Performant Parser of JSONs into Events api(libs.jackson.module.kotlin) - // Parses URLs from Text: - api(libs.url.detector) - // Websockets API implementation(libs.okhttp) implementation(libs.okhttpCoroutines) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 65eb02df7..639e3fb45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.nip01Core.metadata import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.containsAny +import com.vitorpamplona.quartz.utils.startsWithAny import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -47,11 +50,6 @@ class UserMetadata { fun anyName(): String? = displayName ?: name - fun anyNameStartsWith(prefix: String): Boolean = - listOfNotNull(name, displayName, nip05, lud06, lud16).any { - it.contains(prefix, true) - } - fun lnAddress(): String? = lud16 ?: lud06 fun bestName(): String? = displayName ?: name @@ -104,6 +102,33 @@ class UserMetadata { if (pronouns?.isBlank() == true) pronouns = null } + fun anyNameOrAddressContains(terms: List): Boolean = + name?.containsAny(terms) == true || + displayName?.containsAny(terms) == true || + lud16?.containsAny(terms) == true || + nip05?.containsAny(terms) == true || + website?.containsAny(terms) == true + + fun anyNameStartsWith(terms: List): Boolean = + name?.startsWithAny(terms) == true || + displayName?.startsWithAny(terms) == true + + fun anyAddressStartsWith(terms: List): Boolean = + lud16?.startsWithAny(terms) == true || + nip05?.startsWithAny(terms) == true || + website?.startsWithAny(terms) == true + + fun anyPropertyContains(terms: List): Boolean = + name?.containsAny(terms) == true || + displayName?.containsAny(terms) == true || + picture?.containsAny(terms) == true || + banner?.containsAny(terms) == true || + about?.containsAny(terms) == true || + lud06?.containsAny(terms) == true || + lud16?.containsAny(terms) == true || + nip05?.containsAny(terms) == true || + website?.containsAny(terms) == true + fun convertLud06toLud16IfNeeded() { if (lud16.isNullOrBlank()) { lud06?.let { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index 05c25ddcf..668c1ff98 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient @@ -78,6 +79,12 @@ interface INostrClient { fun getReqFiltersOrNull(subId: String): Map>? fun getCountFiltersOrNull(subId: String): Map>? + + fun activeRequests(url: NormalizedRelayUrl): Map> + + fun activeCounts(url: NormalizedRelayUrl): Map> + + fun activeOutboxCache(url: NormalizedRelayUrl): Set } object EmptyNostrClient : INostrClient { @@ -123,4 +130,10 @@ object EmptyNostrClient : INostrClient { override fun getReqFiltersOrNull(subId: String): Map>? = null override fun getCountFiltersOrNull(subId: String): Map>? = null + + override fun activeRequests(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 768588d6c..8d40797e3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -313,11 +313,11 @@ class NostrClient( listeners = listeners.minus(listener) } - fun activeRequests(url: NormalizedRelayUrl): Map> = activeRequests.activeFiltersFor(url) + override fun activeRequests(url: NormalizedRelayUrl): Map> = activeRequests.activeFiltersFor(url) - fun activeCounts(url: NormalizedRelayUrl): Map> = activeCounts.activeFiltersFor(url) + override fun activeCounts(url: NormalizedRelayUrl): Map> = activeCounts.activeFiltersFor(url) - fun activeOutboxCache(url: NormalizedRelayUrl): Set = eventOutbox.activeOutboxCacheFor(url) + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = eventOutbox.activeOutboxCacheFor(url) override fun getReqFiltersOrNull(subId: String): Map>? = activeRequests.getSubscriptionFiltersOrNull(subId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index bd9fb8c3d..c8a4373ff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -128,7 +128,7 @@ open class BasicRelayClient( } catch (e: Throwable) { if (e is CancellationException) throw e // doesn't expose parsing errors to lib users as errors - Log.e("BasicRelayClient", "Failure to parse message from Relay: $text", e) + Log.e("BasicRelayClient", "Failure to parse message from ${url.url}: $text", e) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index 58d3acf8d..64349dcaf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -32,6 +32,8 @@ class RelayStat( var errorCounter: Int = 0, var pingInMs: Int = 0, var compression: Boolean = false, + var connectionTentatives: Int = 0, + var connectionCompleted: Int = 0, ) { val messages = LruCache(100) @@ -63,6 +65,14 @@ class RelayStat( sentBytes += bytesUsedInMemory } + fun newConnection() { + connectionTentatives++ + } + + fun connectionCompleted() { + connectionCompleted++ + } + fun newSpam( link1: String, link2: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index 44c8fb084..fdd0c60ef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -46,6 +46,13 @@ class RelayStats( private val clientListener = object : IRelayClientListener { + override fun onConnecting(relay: IRelayClient) { + super.onConnecting(relay) + with(get(relay.url)) { + newConnection() + } + } + override fun onConnected( relay: IRelayClient, pingMillis: Int, @@ -54,6 +61,7 @@ class RelayStats( with(get(relay.url)) { pingInMs = pingMillis compression = compressed + connectionCompleted() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index a371e1335..69fce5ddd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -88,8 +88,6 @@ class ContactListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey) - fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:" - suspend fun createFromScratch( followUsers: List, relayUse: Map?, @@ -123,6 +121,25 @@ class ContactListEvent( ) } + suspend fun followUsers( + earlierVersion: ContactListEvent, + users: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ContactListEvent { + val follows = earlierVersion.verifiedFollowKeySet() + val toBeAdded = users.filter { it.pubKey !in follows }.distinctBy { it.pubKey }.map { it.toTagArray() } + + if (toBeAdded.isEmpty()) return earlierVersion + + return create( + content = earlierVersion.content, + tags = earlierVersion.tags + toBeAdded, + signer = signer, + createdAt = createdAt, + ) + } + suspend fun unfollowUser( earlierVersion: ContactListEvent, pubKeyHex: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt new file mode 100644 index 000000000..b47acf1f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt @@ -0,0 +1,49 @@ +/* + * 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.nip05DnsIdentifiers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +interface INip05Client { + suspend fun verify( + nip05: Nip05Id, + hexKey: HexKey, + ): Boolean + + suspend fun get(nip05: Nip05Id): Nip05KeyInfo? + + suspend fun load(nip05: Nip05Id): KeyInfoSet? + + suspend fun list(domain: String): KeyInfoSet +} + +class EmptyNip05Client : INip05Client { + override suspend fun verify( + nip05: Nip05Id, + hexKey: HexKey, + ) = true + + override suspend fun get(nip05: Nip05Id): Nip05KeyInfo? = null + + override suspend fun load(nip05: Nip05Id): KeyInfoSet? = null + + override suspend fun list(domain: String): KeyInfoSet = KeyInfoSet(emptyMap(), emptyMap()) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/KeyInfoSet.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/KeyInfoSet.kt new file mode 100644 index 000000000..98305f6bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/KeyInfoSet.kt @@ -0,0 +1,29 @@ +/* + * 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.nip05DnsIdentifiers + +import kotlinx.serialization.Serializable + +@Serializable +data class KeyInfoSet( + val names: Map, + val relays: Map>, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index 66c6724ec..0ac30f796 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -20,22 +20,19 @@ */ package com.vitorpamplona.quartz.nip05DnsIdentifiers +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import kotlinx.coroutines.CancellationException -data class Nip05KeyInfo( - val pubkey: HexKey, - val relays: List, -) - +@Stable class Nip05Client( val fetcher: Nip05Fetcher, val namecoinResolver: NamecoinNameResolver? = null, -) { +) : INip05Client { val parser = Nip05Parser() - suspend fun verify( + override suspend fun verify( nip05: Nip05Id, hexKey: HexKey, ): Boolean { @@ -62,7 +59,7 @@ class Nip05Client( } } - suspend fun get(nip05: Nip05Id): Nip05KeyInfo? { + 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 @@ -72,9 +69,9 @@ class Nip05Client( return parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05)) } - suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05)) + override suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05)) - suspend fun list(domain: String) = parser.parse(fetchNip05Data(domain)) + override suspend fun list(domain: String) = parser.parse(fetchNip05Data(domain)) suspend fun fetchNip05Data(nip05: Nip05Id): String { val url = nip05.toUserUrl() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05KeyInfo.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05KeyInfo.kt new file mode 100644 index 000000000..e25f2d96b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05KeyInfo.kt @@ -0,0 +1,28 @@ +/* + * 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.nip05DnsIdentifiers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +data class Nip05KeyInfo( + val pubkey: HexKey, + val relays: List, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt index 91c7072e5..1614c056d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt @@ -22,18 +22,11 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.text -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -@Serializable -data class KeyInfoSet( - val names: Map, - val relays: Map>, -) - class Nip05Parser { fun toJson(keyInfo: KeyInfoSet) = Json.encodeToString(keyInfo) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index 67e093e07..f31edfe7d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -48,6 +48,30 @@ data class ElectrumxServer( val trustAllCerts: Boolean = false, ) +/** + * Specific exception types for Namecoin resolution failures. + * Allows callers to distinguish "name doesn't exist" from "servers unreachable". + */ +sealed class NamecoinLookupException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { + /** The name was queried successfully but does not exist on the blockchain. */ + class NameNotFound( + val name: String, + ) : NamecoinLookupException("Name not found: $name") + + /** The name has expired (>36000 blocks since last update). */ + class NameExpired( + val name: String, + ) : NamecoinLookupException("Name expired: $name") + + /** All ElectrumX servers were unreachable or returned errors. */ + class ServersUnreachable( + val lastError: Throwable? = null, + ) : NamecoinLookupException("All ElectrumX servers unreachable", lastError) +} + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_ELECTRUMX_SERVERS = listOf( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index b63735330..72abf7546 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -27,6 +27,36 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +/** Detailed outcome of a Namecoin resolution attempt. */ +sealed class NamecoinResolveOutcome { + data class Success( + val result: NamecoinNostrResult, + ) : NamecoinResolveOutcome() + + /** The name does not exist on the Namecoin blockchain. */ + data class NameNotFound( + val name: String, + ) : NamecoinResolveOutcome() + + /** The name exists but has no valid "nostr" field in its value. */ + data class NoNostrField( + val name: String, + ) : NamecoinResolveOutcome() + + /** All ElectrumX servers were unreachable. */ + data class ServersUnreachable( + val message: String, + ) : NamecoinResolveOutcome() + + /** The identifier could not be parsed as a Namecoin name. */ + data class InvalidIdentifier( + val identifier: String, + ) : NamecoinResolveOutcome() + + /** Timed out waiting for a response. */ + data object Timeout : NamecoinResolveOutcome() +} + /** * Result of resolving a Namecoin name to Nostr identity data. */ @@ -88,6 +118,18 @@ class NamecoinNameResolver( } } + /** + * Resolve with detailed outcome for error reporting in UI flows. + */ + suspend fun resolveDetailed(identifier: String): NamecoinResolveOutcome { + val parsed = + parseIdentifier(identifier) + ?: return NamecoinResolveOutcome.InvalidIdentifier(identifier) + val result = + withTimeoutOrNull(lookupTimeoutMs) { performLookupDetailed(parsed) } + return result ?: NamecoinResolveOutcome.Timeout + } + // ── Identifier Parsing ───────────────────────────────────────────── /** @@ -175,6 +217,39 @@ class NamecoinNameResolver( } } + private suspend fun performLookupDetailed(parsed: ParsedIdentifier): NamecoinResolveOutcome { + val nameResult: NameShowResult + try { + nameResult = + electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) + ?: return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.NameNotFound) { + return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.NameExpired) { + return NamecoinResolveOutcome.NameNotFound(parsed.namecoinName) + } catch (e: NamecoinLookupException.ServersUnreachable) { + return NamecoinResolveOutcome.ServersUnreachable( + e.message ?: "All ElectrumX servers unreachable", + ) + } + + val valueJson = + tryParseJson(nameResult.value) + ?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName) + + val nostrResult = + when (parsed.namespace) { + Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed) + Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed) + } + + return if (nostrResult != null) { + NamecoinResolveOutcome.Success(nostrResult) + } else { + NamecoinResolveOutcome.NoNostrField(parsed.namecoinName) + } + } + /** * Extract Nostr data from a `d/` domain value. * @@ -206,16 +281,43 @@ class NamecoinNameResolver( // Extended form: "nostr": { "names": {...}, "relays": {...} } if (nostrField is JsonObject) { val names = nostrField["names"]?.jsonObject ?: return null - val pubkeyElem = names[parsed.localPart] ?: names["_"] // fall back to root - val pubkey = (pubkeyElem as? JsonPrimitive)?.content ?: return null - if (!isValidPubkey(pubkey)) return null + + // Resolve: exact match → "_" root → first entry (root lookups only) + val resolvedLocalPart: String + val pubkey: String + + val exactMatch = names[parsed.localPart] + val rootMatch = names["_"] + val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null + + when { + exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content) -> { + resolvedLocalPart = parsed.localPart + pubkey = exactMatch.content + } + + rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content) -> { + resolvedLocalPart = "_" + pubkey = rootMatch.content + } + + firstEntry != null && firstEntry.value is JsonPrimitive && + isValidPubkey((firstEntry.value as JsonPrimitive).content) -> { + resolvedLocalPart = firstEntry.key + pubkey = (firstEntry.value as JsonPrimitive).content + } + + else -> { + return null + } + } val relays = extractRelays(nostrField, pubkey) return NamecoinNostrResult( pubkey = pubkey.lowercase(), relays = relays, namecoinName = parsed.namecoinName, - localPart = parsed.localPart, + localPart = resolvedLocalPart, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt index 54ebe8424..200fb0481 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure class ContentWarningTag( val reason: String? = null, @@ -33,9 +34,9 @@ class ContentWarningTag( fun isTag(tag: Array) = tag.has(0) && tag[0] == TAG_NAME - fun parse(tags: Array): ContentWarningTag { - require(tags[0] == TAG_NAME) - return ContentWarningTag(tags.getOrNull(1)) + fun parse(tags: Array): ContentWarningTag? { + ensure(tags[0] == TAG_NAME) { return null } + return ContentWarningTag(tags.getOrNull(1)?.ifBlank { null }) } fun assemble() = arrayOfNotNull(TAG_NAME) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt index e2d6cd42d..a025a0cb0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/EventExt.kt @@ -25,3 +25,5 @@ import com.vitorpamplona.quartz.nip01Core.core.Event fun Event.isSensitive() = tags.isSensitive() fun Event.isSensitiveOrNSFW() = tags.isSensitiveOrNSFW() + +fun Event.contentWarningReason() = tags.contentWarningReason() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt index 7d2c54f68..9251bb305 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt @@ -28,3 +28,5 @@ val nsfwTags = setOf("nsfw", "nude", "NSFW", "NUDE", "Nsfw", "Nude") fun TagArray.isSensitive() = this.any(ContentWarningTag::isTag) fun TagArray.isSensitiveOrNSFW() = this.any { ContentWarningTag.isTag(it) || HashtagTag.isAnyTagged(it, nsfwTags) } + +fun TagArray.contentWarningReason() = this.firstNotNullOfOrNull(ContentWarningTag::parse)?.reason diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt new file mode 100644 index 000000000..a6583a80b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt @@ -0,0 +1,43 @@ +/* + * 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.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse + +class ConnectResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed { + if (response.error != null) { + return if (response.error.contains("already connected", ignoreCase = true)) { + SignerResult.RequestAddressed.Successful(ConnectResult.AlreadyConnected) + } else { + SignerResult.RequestAddressed.Rejected() + } + } + + if (response.result != null) { + return SignerResult.RequestAddressed.Successful(ConnectResult.Ack) + } + + return SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 66810e41f..515043d44 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -42,6 +42,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch class NostrSignerRemote( val signer: NostrSignerInternal, @@ -51,6 +56,8 @@ class NostrSignerRemote( val permissions: String? = null, val secret: String? = null, ) : NostrSigner(signer.pubKey) { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val manager = RemoteSignerManager( signer = signer, @@ -69,7 +76,9 @@ class NostrSignerRemote( ), ) { event -> if (event is NostrConnectEvent) { - manager.newResponse(event) + scope.launch { + manager.newResponse(event) + } } } @@ -79,6 +88,7 @@ class NostrSignerRemote( fun closeSubscription() { subscription.close() + scope.cancel() } override fun isWriteable(): Boolean = true @@ -220,7 +230,7 @@ class NostrSignerRemote( throw convertExceptions("Could not ping", result) } - suspend fun connect(): HexKey { + suspend fun connect() { val result = manager.launchWaitAndParse( bunkerRequestBuilder = { @@ -230,11 +240,11 @@ class NostrSignerRemote( secret = secret, ) }, - parser = PubKeyResponse::parse, + parser = ConnectResponse::parse, ) - if (result is SignerResult.RequestAddressed.Successful) { - return result.result.pubkey + if (result is SignerResult.RequestAddressed.Successful) { + return } throw convertExceptions("Could not connect", result) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt index e8a4c6ad1..adae21dfa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt @@ -41,8 +41,9 @@ class RemoteSignerManager( ) { private val awaitingRequests = LargeCache>() - fun newResponse(responseEvent: NostrConnectEvent) { - val bunkerResponse = OptimizedJsonMapper.fromJsonTo(responseEvent.content) + suspend fun newResponse(responseEvent: NostrConnectEvent) { + val decryptedJson = signer.decrypt(responseEvent.content, remoteKey) + val bunkerResponse = OptimizedJsonMapper.fromJsonTo(decryptedJson) awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt index cb7927d10..a0cba9eb9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt @@ -67,3 +67,9 @@ data class PingResult( data class PublicKeyResult( val pubkey: String, ) : IResult + +sealed interface ConnectResult : IResult { + data object Ack : ConnectResult + + data object AlreadyConnected : ConnectResult +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt index c1d61c018..c802c0055 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt @@ -36,13 +36,13 @@ import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag -import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.favoriteRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relayFeeds import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class FavoriteRelayListEvent( +class RelayFeedsListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -58,7 +58,7 @@ class FavoriteRelayListEvent( companion object { const val KIND = 10012 - const val ALT = "Favorite Relays" + const val ALT = "Relay Feeds" val TAGS = arrayOf(AltTag.assemble(ALT)) fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") @@ -68,11 +68,11 @@ class FavoriteRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") suspend fun updateRelayList( - earlierVersion: FavoriteRelayListEvent, + earlierVersion: RelayFeedsListEvent, relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteRelayListEvent { + ): RelayFeedsListEvent { val newRelayList = relays.map { RelayTag.assemble(it) } val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() @@ -86,7 +86,7 @@ class FavoriteRelayListEvent( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteRelayListEvent { + ): RelayFeedsListEvent { val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) } @@ -95,7 +95,7 @@ class FavoriteRelayListEvent( relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): FavoriteRelayListEvent { + ): RelayFeedsListEvent { val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) } @@ -105,14 +105,14 @@ class FavoriteRelayListEvent( privateRelays: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), createdAt = createdAt, ) { alt(ALT) - favoriteRelays(publicRelays) + relayFeeds(publicRelays) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt index 67a12b239..ecbc663ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -27,9 +27,9 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.tags.NameTag @@ -52,4 +52,4 @@ fun TagArrayBuilder.privateRelays(relays: List.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) -fun TagArrayBuilder.favoriteRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) +fun TagArrayBuilder.relayFeeds(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt new file mode 100644 index 000000000..202dc19b9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt @@ -0,0 +1,168 @@ +/* + * 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.nipB7Blossom + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Parsed representation of a BUD-10 Blossom URI. + * + * Format: `blossom:.[?parameters]` + * + * @param sha256 64-character lowercase hex hash of the blob. + * @param extension File extension (e.g. "jpg", "mp4"). Defaults to "bin" when unknown. + * @param servers Server base-URL hints where the blob may exist (`xs` params, tried first). + * @param authors Hex pubkeys of blob uploaders used for BUD-03 server-list lookup (`as` params). + * @param size Blob size in bytes for verification and progress display (`sz` param). + */ +data class BlossomUri( + val sha256: HexKey, + val extension: String, + val servers: List, + val authors: List, + val size: Long?, +) { + /** + * Serialises back to a canonical `blossom:` URI string. + * Server URLs are percent-encoded so that `&`, `=`, and `#` inside them + * do not break the query string. + */ + fun toUriString(): String = + buildString { + append("blossom:") + append(sha256) + append('.') + append(extension) + val params = + buildList { + servers.forEach { add("xs=${percentEncodeQueryValue(it)}") } + authors.forEach { add("as=$it") } + size?.let { add("sz=$it") } + } + if (params.isNotEmpty()) { + append('?') + append(params.joinToString("&")) + } + } + + companion object { + private const val SCHEME = "blossom:" + private val SHA256_REGEX = Regex("^[0-9a-f]{64}$") + + /** + * Parses a BUD-10 URI string into a [BlossomUri], or returns `null` if the + * input is not a valid blossom URI (wrong scheme or malformed SHA-256). + * + * `xs` and `as` parameters may appear multiple times; all values are collected. + */ + fun parse(uri: String): BlossomUri? { + if (!uri.startsWith(SCHEME, ignoreCase = true)) return null + val rest = uri.substring(SCHEME.length) + + val queryIndex = rest.indexOf('?') + val pathPart = if (queryIndex >= 0) rest.substring(0, queryIndex) else rest + val queryPart = if (queryIndex >= 0) rest.substring(queryIndex + 1) else "" + + // Separate sha256 from extension at the last dot. + val dotIndex = pathPart.lastIndexOf('.') + val sha256: String + val extension: String + if (dotIndex > 0) { + sha256 = pathPart.substring(0, dotIndex).lowercase() + extension = pathPart.substring(dotIndex + 1).ifEmpty { "bin" } + } else { + sha256 = pathPart.lowercase() + extension = "bin" + } + + if (!SHA256_REGEX.matches(sha256)) return null + + // Collect repeated query parameters. + val servers = mutableListOf() + val authors = mutableListOf() + var size: Long? = null + + if (queryPart.isNotEmpty()) { + for (param in queryPart.split('&')) { + val eqIndex = param.indexOf('=') + if (eqIndex < 0) continue + val key = param.substring(0, eqIndex) + val value = percentDecode(param.substring(eqIndex + 1)) + when (key) { + "xs" -> if (value.isNotEmpty()) servers.add(value) + "as" -> if (value.isNotEmpty()) authors.add(value) + "sz" -> value.toLongOrNull()?.let { size = it } + } + } + } + + return BlossomUri( + sha256 = sha256, + extension = extension, + servers = servers, + authors = authors, + size = size, + ) + } + + /** Decodes percent-encoded sequences (e.g. `%3A` → `:`). */ + private fun percentDecode(input: String): String { + if ('%' !in input) return input + 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) + val code = hex.toIntOrNull(16) + if (code != null) { + sb.append(code.toChar()) + i += 3 + continue + } + } + sb.append(c) + i++ + } + return sb.toString() + } + + /** + * Percent-encodes characters that are not safe inside a query-string value. + * Letters, digits, and unreserved chars (`-._~`) pass through unchanged. + * Everything else (notably `&`, `=`, `#`, and non-ASCII) is encoded. + */ + private fun percentEncodeQueryValue(input: String): String { + val sb = StringBuilder(input.length) + for (c in input) { + if (c.isLetterOrDigit() || c in "-._~:/?@!$'()*+,;") { + sb.append(c) + } else { + for (b in c.toString().encodeToByteArray()) { + sb.append('%') + sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()) + } + } + } + return sb.toString() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 0e2cf1d0d..eee5e8689 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -89,9 +89,9 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -199,7 +199,7 @@ class EventFactory { CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) - FavoriteRelayListEvent.KIND -> FavoriteRelayListEvent(id, pubKey, createdAt, tags, content, sig) + RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig) JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt index ef76c2119..7a22427f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -68,6 +68,27 @@ fun String.containsIgnoreCase( return false } +fun String.startsWithIgnoreCase( + whatLowercase: String, + whatUppercase: String, +): Boolean { + val termLength = min(whatUppercase.length, whatLowercase.length) + + // If the search term is longer than the string itself, it can't be a prefix + if (this.length < termLength) return false + + var offset = 0 + while (offset < termLength) { + val current = this[offset] + if (current != whatLowercase[offset] && current != whatUppercase[offset]) { + return false + } + offset++ + } + + return true +} + fun String.containsAny(terms: List): Boolean { if (terms.isEmpty()) return true // Empty string is contained @@ -78,7 +99,19 @@ fun String.containsAny(terms: List): Boolean { return terms.any { containsIgnoreCase(it.lowercase, it.uppercase) } } +fun String.startsWithAny(terms: List): Boolean { + if (terms.isEmpty()) return true // Empty string is contained + + if (terms.size == 1) { + return startsWithIgnoreCase(terms[0].lowercase, terms[0].uppercase) + } + + return terms.any { startsWithIgnoreCase(it.lowercase, it.uppercase) } +} + class DualCase( val lowercase: String, val uppercase: String, -) +) { + constructor(anyCase: String) : this(anyCase.lowercase(), anyCase.uppercase()) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt new file mode 100644 index 000000000..538d9ea32 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt @@ -0,0 +1,248 @@ +/* + * 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 + +/** + * Creating own Uri class since java.net.Uri would throw parsing exceptions + * for URL's considered ok by browsers. + * + * Also to avoid further conflict, this does stuff that the normal Uri object doesn't do: + * - Converts http://google.com/a/b/.//./../c to http://google.com/a/c + * - Decodes repeatedly so that http://host/%2525252525252525 becomes http://host/%25 while normal decoders + * would make it http://host/%25252525252525 (one less 25) + * - Removes tabs and new lines: http://www.google.com/foo\tbar\rbaz\n2 becomes "http://www.google.com/foobarbaz2" + * - Converts IP addresses: http://3279880203/blah becomes http://195.127.0.11/blah + * - Strips fragments (anything after #) + * + */ +class Url( + val urlMarker: UrlMarker, + val originalUrl: String, +) { + private var _scheme: String? = null + private var _username: String? = null + private var _password: String? = null + private var rawHost: String? = null + private var _port = 0 + private var rawPath: String? = null + private var _query: String? = null + private var _fragment: String? = null + + override fun toString(): String = this.fullUrl + + /** + * Note that this includes the fragment + * @return Formats the url to: [scheme]://[username]:[password]@[host]:[port]/[path]?[query]#[fragment] + */ + val fullUrl: String + get() = this.fullUrlWithoutFragment + this.fragment + + /** + * + * @return Formats the url to: [scheme]://[username]:[password]@[host]:[port]/[path]?[query] + */ + val fullUrlWithoutFragment: String + get() { + val url = StringBuilder() + if (this.scheme.isNotEmpty()) { + url.append(this.scheme) + url.append(":") + } + url.append("//") + + if (this.username.isNotEmpty()) { + url.append(this.username) + if (this.password.isNotEmpty()) { + url.append(":") + url.append(this.password) + } + url.append("@") + } + + url.append(this.host) + if (this.port > 0 && this.port != SCHEME_PORT_MAP[this.scheme]) { + url.append(":") + url.append(this.port) + } + + url.append(this.path) + url.append(this.query) + + return url.toString() + } + + val scheme: String + get() { + if (_scheme == null) { + if (exists(UrlPart.SCHEME)) { + _scheme = getPart(UrlPart.SCHEME) + val index = _scheme!!.indexOf(":") + if (index != -1) { + _scheme = _scheme!!.substring(0, index) + } + } else if (!originalUrl.startsWith("//")) { + _scheme = + DEFAULT_SCHEME + } + } + return _scheme ?: "" + } + + val username: String + get() { + if (_username == null) { + populateUsernamePassword() + } + return _username ?: "" + } + + val password: String + get() { + if (_password == null) { + populateUsernamePassword() + } + return _password ?: "" + } + + val host: String + get() { + if (this.rawHost == null) { + this.rawHost = getPart(UrlPart.HOST) + if (exists(UrlPart.PORT)) { + this.rawHost = + rawHost?.let { + it.substring(0, it.length - 1) + } + } + } + return this.rawHost!! + } + + /** + * port = 0 means it hasn't been set yet. port = -1 means there is no port + */ + val port: Int + get() { + if (_port == 0) { + val portString = + getPart(UrlPart.PORT) + if (!portString.isNullOrEmpty()) { + _port = portString.toIntOrNull() ?: -1 + } else { + _port = SCHEME_PORT_MAP[this.scheme] ?: -1 + } + } + return _port + } + + val path: String? + get() { + if (this.rawPath == null) { + this.rawPath = + if (exists(UrlPart.PATH)) { + getPart( + UrlPart.PATH, + ) + } else { + "/" + } + } + return this.rawPath + } + + val query: String + get() { + if (_query == null) { + _query = getPart(UrlPart.QUERY) + } + return _query ?: "" + } + + val fragment: String + get() { + if (_fragment == null) { + _fragment = getPart(UrlPart.FRAGMENT) + } + return _fragment ?: "" + } + + private fun populateUsernamePassword() { + val usernamePassword = getPart(UrlPart.USERNAME_PASSWORD) + if (usernamePassword != null) { + val usernamePasswordParts: List = + usernamePassword.substring(0, usernamePassword.length - 1).split(":") + if (usernamePasswordParts.size == 1) { + _username = usernamePasswordParts[0] + } else if (usernamePasswordParts.size == 2) { + _username = usernamePasswordParts[0] + _password = usernamePasswordParts[1] + } + } + } + + /** + * @param urlPart The url part we are checking for existence + * @return Returns true if the part exists. + */ + private fun exists(urlPart: UrlPart?): Boolean = urlPart != null && urlMarker.indexOf(urlPart) >= 0 + + /** + * For example, in http://yahoo.com/lala/, nextExistingPart(UrlPart.HOST) would return UrlPart.PATH + * @param urlPart The current url part + * @return Returns the next part; if there is no existing next part, it returns null + */ + private fun nextExistingPart(urlPart: UrlPart): UrlPart? { + val nextPart = urlPart.nextPart + if (exists(nextPart)) { + return nextPart + } else if (nextPart == null) { + return null + } else { + return nextExistingPart(nextPart) + } + } + + /** + * @param part The part that we want. Ex: host, path + */ + private fun getPart(part: UrlPart): String? { + if (!exists(part)) { + return null + } + + val nextPart = nextExistingPart(part) + return if (nextPart == null) { + originalUrl.substring(urlMarker.indexOf(part)) + } else { + originalUrl.substring(urlMarker.indexOf(part), urlMarker.indexOf(nextPart)) + } + } + + companion object { + private const val DEFAULT_SCHEME = "https" + private val SCHEME_PORT_MAP: Map = + mapOf( + "http" to 80, + "https" to 443, + "ftp" to 21, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarker.kt new file mode 100644 index 000000000..7a94f90f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarker.kt @@ -0,0 +1,97 @@ +/* + * 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 + +class UrlMarker { + private var schemeIndex = -1 + private var usernamePasswordIndex = -1 + private var hostIndex = -1 + private var portIndex = -1 + private var pathIndex = -1 + private var queryIndex = -1 + private var fragmentIndex = -1 + + var hasChanged: Boolean = false + private set + + fun createUrl(originalUrl: String): Url = Url(this, originalUrl) + + fun setIndex( + urlPart: UrlPart, + index: Int, + ) { + hasChanged = true + when (urlPart) { + UrlPart.SCHEME -> schemeIndex = index + UrlPart.USERNAME_PASSWORD -> usernamePasswordIndex = index + UrlPart.HOST -> hostIndex = index + UrlPart.PORT -> portIndex = index + UrlPart.PATH -> pathIndex = index + UrlPart.QUERY -> queryIndex = index + UrlPart.FRAGMENT -> fragmentIndex = index + } + } + + fun hasScheme() = schemeIndex >= 0 + + fun hasPort() = portIndex >= 0 + + fun hasUsernamePassword() = usernamePasswordIndex >= 0 + + fun hasQuery() = queryIndex >= 0 + + fun hasFragment() = fragmentIndex >= 0 + + /** + * @param urlPart The part you want the index of + * @return Returns the index of the part + */ + fun indexOf(urlPart: UrlPart): Int = + when (urlPart) { + UrlPart.SCHEME -> schemeIndex + UrlPart.USERNAME_PASSWORD -> usernamePasswordIndex + UrlPart.HOST -> hostIndex + UrlPart.PORT -> portIndex + UrlPart.PATH -> pathIndex + UrlPart.QUERY -> queryIndex + UrlPart.FRAGMENT -> fragmentIndex + } + + fun unsetIndex(urlPart: UrlPart) { + setIndex(urlPart, -1) + } + + /** + * This is used in TestUrlMarker to set indices more easily. + * @param indices array of indices of size 7 + */ + fun setIndices(indices: IntArray): UrlMarker { + require(indices.size == 7) { "Malformed index array." } + setIndex(UrlPart.SCHEME, indices[0]) + setIndex(UrlPart.USERNAME_PASSWORD, indices[1]) + setIndex(UrlPart.HOST, indices[2]) + setIndex(UrlPart.PORT, indices[3]) + setIndex(UrlPart.PATH, indices[4]) + setIndex(UrlPart.QUERY, indices[5]) + setIndex(UrlPart.FRAGMENT, indices[6]) + return this + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlPart.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlPart.kt new file mode 100644 index 000000000..869be45cd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlPart.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.urldetector + +enum class UrlPart( + nextPart: UrlPart?, +) { + FRAGMENT(null), + QUERY(FRAGMENT), + PATH(QUERY), + PORT(PATH), + HOST(PORT), + USERNAME_PASSWORD(HOST), + SCHEME(USERNAME_PASSWORD), + ; + + val nextPart: UrlPart? = nextPart +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/CharUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/CharUtils.kt new file mode 100644 index 000000000..be184ce3e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/CharUtils.kt @@ -0,0 +1,86 @@ +/* + * 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.detection + +object CharUtils { + /** + * Checks if character is a valid hex character. + */ + fun isHex(a: Char): Boolean = (a in '0'..'9') || (a in 'a'..'f') || (a in 'A'..'F') + + /** + * Checks if character is a valid alphabetic character. + */ + fun isAlpha(a: Char): Boolean = ((a in 'a'..'z') || (a in 'A'..'Z')) + + /** + * Checks if character is a valid numeric character. + */ + fun isNumeric(a: Char): Boolean = a in '0'..'9' + + /** + * Checks if character is a valid alphanumeric character. + */ + fun isAlphaNumeric(a: Char): Boolean = isAlpha(a) || isNumeric(a) + + /** + * Checks if character is a valid unreserved character. This is defined by the RFC 3986 ABNF + */ + fun isUnreserved(a: Char): Boolean = isAlphaNumeric(a) || a == '-' || a == '.' || a == '_' || a == '~' + + /** + * Checks if character is a dot. Heres the doc: + * http://docs.oracle.com/javase/6/docs/api/java/net/IDN.html#toASCII%28java.lang.String,%20int%29 + */ + fun isDot(a: Char): Boolean = (a == '.' || a == '\u3002' || a == '\uFF0E' || a == '\uFF61') + + fun isWhiteSpace(a: Char): Boolean = (a == '\n' || a == '\t' || a == '\r' || a == ' ') + + /** + * Splits a string without the use of a regex, which could split either by isDot() or %2e + * @param input the input string that will be split by dot + * @return an array of strings that is a partition of the original string split by dot + */ + fun splitByDot(input: String): List { + val splitList = ArrayList() + val section = StringBuilder() + if (input.isEmpty()) { + return listOf("") + } + val reader = InputTextReader(input) + while (!reader.eof()) { + val curr: Char = reader.read() + if (isDot(curr)) { + splitList.add(section.toString()) + section.setLength(0) + } else if (curr == '%' && (reader.peekEquals("2e") || reader.peekEquals("2E"))) { + reader.read() + reader.read() // advance past the 2e + splitList.add(section.toString()) + section.setLength(0) + } else { + section.append(curr) + } + } + splitList.add(section.toString()) + return splitList + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt new file mode 100644 index 000000000..ebe64927c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt @@ -0,0 +1,785 @@ +/* + * 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.detection + +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.isAlpha +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.isAlphaNumeric +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.isDot +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.isHex +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.isNumeric +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.isUnreserved +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.splitByDot +import kotlin.math.max +import kotlin.math.min + +/** Returns true if the string is a percent-encoded dot (%2e or %2E). */ +private fun String.isDotPercent() = this == "%2e" || this == "%2E" + +/** Returns true if the string is an internationalized domain label using Punycode (xn-- prefix). */ +private fun String.isXn() = + this.length > 3 && + (this[0] == 'x' || this[0] == 'X') && + (this[1] == 'n' || this[1] == 'N') && + this[2] == '-' && + this[3] == '-' + +/** + * The domain name reader reads input from a InputTextReader and validates if the content being read is a valid domain name. + * After a domain name is read, the returning status is what to do next. If the domain is valid but a specific character is found, + * the next state will be to read another part for the rest of the url. For example, if a "?" is found at the end and the + * domain is valid, the return state will be to read a query string. + */ +class DomainNameReader( + val reader: InputTextReader, + /** + * The currently written string buffer. + */ + val buffer: StringBuilder, + /** + * The domain name started with a partial domain name found. This is the original string of the domain name only. + */ + val current: String?, +) { + /** + * This is the final return state of reading a domain name. + */ + enum class ReaderNextState { + /** + * Trying to read the domain name caused it to be invalid. + */ + InvalidDomainName, + + /** + * The domain name is found to be valid. + */ + ValidDomainName, + + /** + * Finished reading, next step should be to read the fragment. + */ + ReadFragment, + + /** + * Finished reading, next step should be to read the path. + */ + ReadPath, + + /** + * Finished reading, next step should be to read the port. + */ + ReadPort, + + /** + * Finished reading, next step should be to read the query string. + */ + ReadQueryString, + + /** + * This was actually not a domain at all. + */ + ReadUserPass, + } + + /** + * Keeps track the number of dots that were found in the domain name. + */ + private var dots = 0 + + /** + * Keeps track of the number of characters since the last "." + */ + private var currentLabelLength = 0 + + /** + * Keeps track of the number of characters in the top level domain. + */ + private var topLevelLength = 0 + + /** + * Keeps track where the domain name started. This is non zero if the buffer starts with + * http://username:password@... + */ + private var startDomainName = 0 + + /** + * Keeps track if the entire domain name is numeric. + */ + private var numeric = false + + /** + * Keeps track if we are seeing an ipv6 type address. + */ + private var seenBracket = false + + /** + * Keeps track if we have seen a full bracket set "[....]"; used for ipv6 type address. + */ + private var seenCompleteBracketSet = false + + /** + * Keeps track if we have a zone index in the ipv6 address. + */ + private var zoneIndex = false + + var isIpV4 = false + private set + + var isIpV6 = false + private set + + /** + * Reads and parses the current string to make sure the domain name started where it was supposed to, + * and the current domain name is correct. + * @return The next state to use after reading the current. + */ + private fun readCurrent(): ReaderNextState { + if (current != null) { + // Handles the case where the string is ".hello" + if (current.length == 1 && isDot(current[0])) { + return ReaderNextState.InvalidDomainName + } else if (current.length == 3 && current.isDotPercent()) { + return ReaderNextState.InvalidDomainName + } + + // The location where the domain name started. + startDomainName = buffer.length - current.length + + // flag that the domain is currently all numbers and/or dots. + numeric = true + + // If an invalid char is found, we can just restart the domain from there. + var newStart = 0 + + val currArray = current.toCharArray() + val length = currArray.size + + // hex special case + var isAllHexSoFar = + length > 2 && (currArray[0] == '0' && (currArray[1] == 'x' || currArray[1] == 'X')) + + var lastWasAscii = length > 0 && currArray[0].code < INTERNATIONAL_CHAR_START + + var index = if (isAllHexSoFar) 2 else 0 + var done = false + var isAscii = false + + while (index < length && !done) { + // get the current character and update length counts. + val curr = currArray[index] + isAscii = curr.code < INTERNATIONAL_CHAR_START + + currentLabelLength++ + topLevelLength = currentLabelLength + + // Is the length of the last part > 64 (plus one since we just incremented) + if (currentLabelLength > MAX_LABEL_LENGTH) { + return ReaderNextState.InvalidDomainName + } else if (isDot(curr)) { + // found a dot. Increment dot count, and reset last length + dots++ + currentLabelLength = 0 + } else if (curr == '[') { + seenBracket = true + numeric = false + } else if (curr == '%' && index + 2 < length && isHex(currArray[index + 1]) && isHex(currArray[index + 2])) { + // handle url encoded dot + if (currArray[index + 1] == '2' && currArray[index + 2] == 'e') { + dots++ + currentLabelLength = 0 + } else { + numeric = false + } + index += 2 + } else if (isAllHexSoFar) { + // if it's a valid character in the domain that is not numeric + if (!isHex(curr)) { + numeric = false + isAllHexSoFar = false + index-- // backtrack to rerun last character knowing it isn't hex. + } + } else if (isAscii == lastWasAscii && (isAlpha(curr) || curr == '-' || !isAscii)) { + // we don't allow mixed domains: doesn't come here if it changed form ascii to not ascii. + numeric = false + lastWasAscii = isAscii + } else if (isAscii != lastWasAscii) { + // if its not _numeric and not alphabetical, then restart searching for a domain from this point. + newStart = index + currentLabelLength = 0 + topLevelLength = 0 + numeric = true + dots = 0 + // done = true + + lastWasAscii = isAscii + } else if (index == 0) { + if (curr in UrlDetector.CANNOT_BEGIN_URLS_WITH) { + newStart = index + 1 + currentLabelLength = 0 + topLevelLength = 0 + numeric = true + dots = 0 + } + } + index++ + } + + // An invalid character for the domain was found somewhere in the current buffer. + // cut the first part of the domain out. For example: + // http://asdf%asdf.google.com <- asdf.google.com is still valid, so restart from the % + if (newStart > 0) { + // make sure the location is not at the end. Otherwise the thing is just invalid. + + if (newStart < current.length) { + buffer.clear() + buffer.append(current.substring(newStart)) + + // cut out the previous part, so now the domain name has to be from here. + startDomainName = 0 + } + + // now after cutting if the buffer is just "." newStart > current (last character in current is invalid) + if (newStart >= current.length || buffer.toString() == ".") { + return ReaderNextState.InvalidDomainName + } + } + } else { + startDomainName = buffer.length + } + + // all else is good, return OK + return ReaderNextState.ValidDomainName + } + + /** + * Reads the Dns and returns the next state the state machine should take in throwing this out, or continue processing + * if this is a valid domain name. + * @return The next state to take. + */ + fun readDomainName(): ReaderNextState? { + // Read the current, and if its bad, just return. + + if (readCurrent() == ReaderNextState.InvalidDomainName) { + return ReaderNextState.InvalidDomainName + } + + // while not done and not end of string keep reading. + var done = false + + // If this is the first domain part, check if it's ip address in is hexa + // similar to what is done on 'readCurrent' method + val isAllHexSoFar = + (current == null || current == "") && + reader.canReadChars(3) && + (reader.peekEquals("0x") || reader.peekEquals("0X")) + + if (isAllHexSoFar) { + // Append hexa radix symbol characters (0x) + buffer.append(reader.read()) + buffer.append(reader.read()) + currentLabelLength += 2 + topLevelLength = currentLabelLength + } + + var lastWasAscii: Boolean? = null + var isAscii = false + + while (!done && !reader.eof()) { + val curr: Char = reader.read() + isAscii = curr.code < INTERNATIONAL_CHAR_START + if (lastWasAscii == null) { + lastWasAscii = isAscii + } + + if (curr == '/') { + // continue by reading the path + return checkDomainNameValid(ReaderNextState.ReadPath, curr) + } else if (curr == ':' && (!seenBracket || seenCompleteBracketSet)) { + // Don't check for a port if it's in the middle of an ipv6 address + // continue by reading the port. + return checkDomainNameValid(ReaderNextState.ReadPort, curr) + } else if (curr == '?') { + // continue by reading the query string + return checkDomainNameValid(ReaderNextState.ReadQueryString, curr) + } else if (curr == '#') { + // continue by reading the fragment + return checkDomainNameValid(ReaderNextState.ReadFragment, curr) + } else if (curr == '@') { + // this may not have been a domain after all, but rather a username/password instead + reader.goBack() + return ReaderNextState.ReadUserPass + } else if (isDot(curr) || (curr == '%' && (reader.peekEquals("2e") || reader.peekEquals("2E")))) { + // if the current character is a dot or a urlEncodedDot + + // handles the case: hello.. + + if (currentLabelLength < 1) { + done = true + } else { + // append the "." to the domain name + buffer.append(curr) + + // if it was not a normal dot, then it is url encoded + // read the next two chars, which are the hex representation + if (!isDot(curr)) { + buffer.append(reader.read()) + buffer.append(reader.read()) + } + + // increment the dots only if it's not part of the zone index and reset the last length. + if (!zoneIndex) { + dots++ + currentLabelLength = 0 + } + + // if the length of the last section is longer than or equal to 64, it's too long to be a valid domain + if (currentLabelLength >= MAX_LABEL_LENGTH) { + return ReaderNextState.InvalidDomainName + } + } + } else if (seenBracket && (isHex(curr) || curr == ':' || curr == '[' || curr == ']' || curr == '%') && !seenCompleteBracketSet) { // if this is an ipv6 address. + when (curr) { + ':' -> { + currentLabelLength = 0 + } + + '[' -> { + // if we read another '[', we need to restart by re-reading from this bracket instead. + reader.goBack() + return ReaderNextState.InvalidDomainName + } + + ']' -> { + seenCompleteBracketSet = + true // means that we already have a complete ipv6 address. + zoneIndex = + false // set this back off so that we can keep counting dots after ipv6 is over. + } + + '%' -> { + zoneIndex = true + } + + else -> { + currentLabelLength++ + } + } + numeric = false + buffer.append(curr) + } else if (isAlphaNumeric(curr) || curr == '-' || curr.code >= INTERNATIONAL_CHAR_START) { + // Valid domain name character. Either a-z, A-Z, 0-9, -, or international character + if (seenCompleteBracketSet) { + // covers case of [fe80::]www.google.com + reader.goBack() + done = true + } else { + if (isAllHexSoFar && !isHex(curr)) { + numeric = false + } + // if its not numeric, remember that; + if (!isAllHexSoFar && !isNumeric(curr)) { + numeric = false + } + if (isAscii != lastWasAscii) { + reader.goBack() + done = true + } else { + // append to the states. + buffer.append(curr) + currentLabelLength++ + topLevelLength = currentLabelLength + } + } + + lastWasAscii = isAscii + } else if (curr == '[' && !seenBracket) { + seenBracket = true + numeric = false + buffer.append(curr) + } else if (curr == '[' && seenCompleteBracketSet) { // Case where [::][ ... + reader.goBack() + done = true + } else if (curr == '%' && reader.canReadChars(2) && isHex(reader.peekChar(0)) && isHex(reader.peekChar(1))) { + // append to the states. + buffer.append(curr) + buffer.append(reader.read()) + buffer.append(reader.read()) + currentLabelLength += 3 + topLevelLength = currentLabelLength + } else { + // invalid character, we are done. + done = true + } + } + + // Check the domain name to make sure its ok. + return checkDomainNameValid(ReaderNextState.ValidDomainName, null) + } + + fun labelCount() = dots + (if (currentLabelLength > 0) 1 else 0) + + /** + * Checks the current state of this object and returns if the valid state indicates that the + * object has a valid domain name. If it does, it will return append the last character + * and return the validState specified. + * @param validState The state to return if this check indicates that the dns is ok. + * @param lastChar The last character to add if the domain is ok. + * @return The validState if the domain is valid, else ReaderNextState.InvalidDomainName + */ + private fun checkDomainNameValid( + validState: ReaderNextState?, + lastChar: Char?, + ): ReaderNextState? { + var valid = false + + // Max domain length is 255 which includes the trailing "." + // most of the time this is not included in the url. + // If the _currentLabelLength is not 0 then the last "." is not included so add it. + // Same with number of labels (or dots including the last) + val lastDotLength = + if (buffer.length > 3 && + buffer[buffer.length - 3] == '%' && + buffer[buffer.length - 2] == '2' && + (buffer[buffer.length - 1] == 'e' || buffer[buffer.length - 1] == 'E') + ) { + 3 + } else { + 1 + } + + val domainLength: Int = + buffer.length - startDomainName + (if (currentLabelLength > 0) lastDotLength else 0) + val dotCount = dots + (if (currentLabelLength > 0) 1 else 0) + if (domainLength >= MAX_DOMAIN_LENGTH || dotCount > MAX_NUMBER_LABELS) { + valid = false + } else if (numeric) { + val testDomain = buffer.substring(startDomainName).lowercase() + valid = isValidIpv4(testDomain) + if (valid) { + isIpV4 = true + } + } else if (seenBracket) { + val testDomain = buffer.substring(startDomainName).lowercase() + valid = isValidIpv6(testDomain) + if (valid) { + isIpV6 = true + } + } else if (buffer.isNotEmpty() && buffer.last() == ':') { + valid = false + } else if ((currentLabelLength > 0 && dots >= 1) || (dots >= 2 && currentLabelLength == 0) || (dots == 0)) { + var topStart: Int = buffer.length - topLevelLength + if (currentLabelLength == 0) { + topStart-- + } + topStart = max(topStart, 0) + + // get the first 4 characters of the top level domain + val topLevelStart = + buffer.substring(topStart, topStart + min(4, buffer.length - topStart)) + + // There is no size restriction if the top level domain is international (starts with "xn--") + valid = (topLevelStart.isXn() || topLevelLength >= MIN_TOP_LEVEL_DOMAIN) + } + + if (valid) { + // if it's valid, add the last character (if specified) and return the valid state. + if (lastChar != null) { + buffer.append(lastChar) + } + return validState + } + + // return invalid state. + return ReaderNextState.InvalidDomainName + } + + /** + * Handles Hexadecimal, octal, decimal, dotted decimal, dotted hex, dotted octal. + * @param testDomain the string we're testing + * @return Returns true if it's a valid ipv4 address + */ + private fun isValidIpv4(testDomain: String): Boolean { + var valid = false + val length: Int = testDomain.length + if (length > 0) { + // handling format without dots. Ex: http://2123123123123/path/a, http://0x8242343/aksdjf + if (dots == 0) { + try { + val value: Long + if (length > 2 && testDomain[0] == '0' && testDomain[1] == 'x') { // hex + // digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f'] + for (c in 2.. 'f')) { + return false + } + } + value = testDomain.substring(2).toLong(16) + } else if (testDomain[0] == '0') { // octal + // digit must be within ['0', '7'] + for (c in 1.. = splitByDot(testDomain) + valid = true + + // check each part of the ip and make sure its valid. + var i = 0 + while (i < parts.size && valid) { + val part = parts[i] + val partLen: Int = part.length + if (partLen > 0) { + val parsedNum: String + val base: Int + if (partLen > 2 && part[0] == '0' && part[1] == 'x') { // dotted hex + // digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f'] + for (c in 2.. 'f')) { + return false + } + } + parsedNum = part.substring(2) + base = 16 + } else if (part[0] == '0') { // dotted octal + // digit must be within ['0', '7'] + for (c in 1.. {} + + '%', ']' -> { + var out = false + + if (domainArray[index] == '%') { + // see if there's a urlencoded dot + if (domainArray.size - index >= 2 && domainArray[index + 1] == '2' && domainArray[index + 2] == 'e') { + lastSection.append("%2e") + index += 2 + hexSection = false + out = true + } + if (!out) zoneIndiceMode = true + } + if (!out) { + if (!hexSection && (!zoneIndiceMode || domainArray[index] == '%')) { + if (isValidIpv4(lastSection.toString())) { + numSections++ // ipv4 takes up 2 sections. + } else { + return false + } + } + } + } + + ':' -> { + if (prevChar == ':') { + if (doubleColonFlag) { // only allowed to have one "::" in an ipv6 address. + return false + } + doubleColonFlag = true + } + + // This means that we reached invalid characters in the previous section + if (!hexSection) { + return false + } + + hexSection = true // reset hex to true + hexDigits = 0 // reset count for hex digits + numSections++ + lastSection.clear() // clear last section + } + + else -> { + if (zoneIndiceMode) { + if (!isUnreserved(domainArray[index])) { + return false + } + } else { + lastSection.append(domainArray[index]) // collect our possible ipv4 address + if (hexSection && isHex(domainArray[index])) { + hexDigits++ + } else { + hexSection = false // non hex digit. + } + } + } + } + + if (hexDigits > 4 || numSections > 8) { + return false + } + prevChar = domainArray[index] + index++ + } + + // numSections != 1 checks for things like: [adf] + // If there are more than 8 sections for the address or there isn't a double colon, then it's invalid. + return numSections != 1 && (numSections >= 8 || doubleColonFlag) + } + + companion object { + /** + * The minimum length of a ascii based top level domain. + */ + private const val MIN_TOP_LEVEL_DOMAIN = 2 + + /** + * The maximum number that the url can be in a url that looks like: + * http://123123123123/path + */ + private const val MAX_NUMERIC_DOMAIN_VALUE = 4294967295L + + /** + * The minimum number the url can be in a url that looks like: + * http://123123123123/path + */ + private const val MIN_NUMERIC_DOMAIN_VALUE = 16843008L + + /** + * If the domain name is an ip address, for each part of the address, whats the minimum value? + */ + private const val MIN_IP_PART = 0 + + /** + * If the domain name is an ip address, for each part of the address, whats the maximum value? + */ + private const val MAX_IP_PART = 255 + + /** + * The start of the utf character code table which indicates that this character is an international character. + * Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name. + */ + private const val INTERNATIONAL_CHAR_START = 192 + + /** + * The maximum length of each label in the domain name. + */ + private const val MAX_LABEL_LENGTH = 1000 + + /** + * The maximum number of labels in a single domain name. + */ + private const val MAX_NUMBER_LABELS = 127 + + /** + * The maximum domain name length. + */ + private const val MAX_DOMAIN_LENGTH = 1000 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/InputTextReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/InputTextReader.kt new file mode 100644 index 000000000..8b91d1ec4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/InputTextReader.kt @@ -0,0 +1,101 @@ +/* + * 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.detection + +import com.vitorpamplona.quartz.utils.urldetector.detection.CharUtils.isWhiteSpace + +/** + * Class used to read a text input character by character. This also gives the ability to backtrack. + */ +class InputTextReader( + content: String, +) { + /** + * The content to read. + */ + private val content: CharArray = content.toCharArray() + + /** + * The current position in the content we are looking at. + */ + var position: Int = 0 + private set + + /** + * Reads a single char from the content stream and increments the index. + * @return The next available character. + */ + fun read(): Char { + val chr = content[this.position++] + return if (isWhiteSpace(chr)) ' ' else chr + } + + /** + * Peeks at the next number of chars and returns as a string without incrementing the current index. + * @param str The string to compare to + */ + fun peekEquals(str: String): Boolean { + if (position + str.length > content.size) return false + for (i in str.indices) { + if (content[position + i] != str[i]) return false + } + return true + } + + /** + * Gets the character in the array offset by the current index. + * @param offset The number of characters to offset. + * @return The character at the location of the index plus the provided offset. + */ + fun peekChar(offset: Int): Char { + if (!canReadChars(offset)) { + throw IllegalArgumentException("Index out of bounds") + } + + return content[this.position + offset] + } + + /** + * Returns true if the reader has more the specified number of chars. + * @param numberChars The number of chars to see if we can read. + * @return True if we can read this number of chars, else false. + */ + fun canReadChars(numberChars: Int): Boolean = content.size >= this.position + numberChars + + /** + * Checks if the current stream is at the end. + * @return True if the stream is at the end and no more can be read. + */ + fun eof(): Boolean = content.size <= this.position + + /** + * Moves the index to the specified position. + * @param position The position to set the index to. + */ + fun seek(position: Int) { + this.position = position + } + + /** + * Goes back a single character. + */ + fun goBack() = this.position-- +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt new file mode 100644 index 000000000..e29bf7773 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -0,0 +1,717 @@ +/* + * 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.detection + +import com.vitorpamplona.quartz.utils.urldetector.Url +import com.vitorpamplona.quartz.utils.urldetector.UrlMarker +import com.vitorpamplona.quartz.utils.urldetector.UrlPart +import kotlin.math.max +import kotlin.text.deleteRange + +class UrlDetector( + content: String, +) { + /** + * The input stream to read. + */ + private val reader: InputTextReader = InputTextReader(content) + + /** + * Buffer to store temporary urls inside of. + */ + private val buffer = StringBuilder() + + /** + * Has the scheme been found in this iteration? + */ + private var hasScheme = false + + /** + * has Multi-level labels + */ + private var isSingleLevelLabel = false + + /** + * Stores the found urls. + */ + private val urlList = mutableListOf() + + /** + * Keeps track of certain indices to create a Url object. + */ + private var currentUrlMarker: UrlMarker = UrlMarker() + + /** + * The states to use to continue writing or not. + */ + enum class ReadEndState { + ValidUrl, + InvalidUrl, + } + + /** + * Detects the urls and returns a list of detected url strings. + * @return A list with detected urls. + */ + fun detect(): List { + readDefault() + return urlList + } + + /** + * The default input reader which looks for specific flags to start detecting the url. + */ + private fun readDefault() { + // Keeps track of the number of characters read to be able to later cut out the domain name. + var length = 0 + var position = 0 + + // until end of string read the contents + while (!reader.eof()) { + // read the next char to process. + when (val curr = reader.read()) { + ' ' -> { + // space found; if we have a scheme, attempt to read the domain before resetting + if (buffer.isNotEmpty() && hasScheme) { + reader.goBack() + readDomainName(buffer.substring(length)) + } + readEnd(ReadEndState.InvalidUrl) + length = 0 + } + + '%' -> { + if (reader.canReadChars(2)) { + if (reader.peekEquals("3a") || reader.peekEquals("3A")) { + buffer.append(curr) + buffer.append(reader.read()) + buffer.append(reader.read()) + length = processColon(length) + } else if (CharUtils.isHex(reader.peekChar(0)) && CharUtils.isHex(reader.peekChar(1))) { + buffer.append(curr) + buffer.append(reader.read()) + buffer.append(reader.read()) + + if (!readDomainName(buffer.substring(length))) { + readEnd(ReadEndState.InvalidUrl) + } + length = 0 + } + } + } + + '\u3002', '\uFF0E', '\uFF61', '.' -> { + buffer.append(curr) + val domain = buffer.substring(length) + if (!readDomainName(domain)) { + readEnd(ReadEndState.InvalidUrl) + } + length = 0 + } + + '@' -> { + if (buffer.isNotEmpty()) { + currentUrlMarker.setIndex(UrlPart.USERNAME_PASSWORD, length) + buffer.append(curr) + if (!readDomainName(null)) { + readEnd(ReadEndState.InvalidUrl) + } + length = 0 + } + } + + '[' -> { + val beginning = reader.position + + // if it doesn't have a scheme, clear the buffer. + if (!hasScheme) { + buffer.clear() + } + buffer.append(curr) + + if (!readDomainName(buffer.substring(length))) { + // if we didn't find an ipv6 address, then check inside the brackets for urls + readEnd(ReadEndState.InvalidUrl) + reader.seek(beginning) + } + length = 0 + } + + '/' -> { + // "/" was found, then we either read a scheme, or if we already read a scheme, then + // we are reading a url in the format http://123123123/asdf + if (hasScheme || buffer.length > 1) { + // we already have the scheme, so then we already read: + // http://something/ <- if something is all numeric then its a valid url. + // OR we are searching for single level domains. We have buffer length > 1 condition + // to weed out infinite backtrack in cases of html5 roots + + // unread this "/" and continue to check the domain name starting from the beginning of the domain + + reader.goBack() + if (!readDomainName(buffer.substring(length))) { + readEnd(ReadEndState.InvalidUrl) + } + length = 0 + } else { + // we don't have a scheme already, then clear state, then check for html5 root such as: "//google.com/" + // remember the state of the quote when clearing state just in case its "//google.com" so its not cleared. + + readEnd(ReadEndState.InvalidUrl) + buffer.append(curr) + hasScheme = readHtml5Root() + length = buffer.length + } + } + + ':' -> { + // add the ":" to the url and check for scheme/username + buffer.append(curr) + length = processColon(length) + } + + else -> { + buffer.append(curr) + } + } + + if (position == reader.position) { + // we haven't made any progress, advance by one char + reader.read() + } + + position = reader.position + } + + // check if it's a valid single level domain. + if (buffer.isNotEmpty() && hasScheme) { + if (!readDomainName(buffer.substring(length))) { + readEnd(ReadEndState.InvalidUrl) + } + } + } + + /** + * We found a ":" and is now trying to read either scheme, username/password + * @param startLength first index of the previous part (could be beginning of the buffer, beginning of the username/password, or beginning + * @return new index of where the domain starts + */ + private fun processColon(startLength: Int): Int { + var length = startLength + if (hasScheme) { + // read it as username/password if it has scheme + if (!readUserPass(length)) { + // unread the ":" so that the domain reader can process it + reader.goBack() + + // Check buffer length before clearing it; set length to 0 if buffer is empty + if (buffer.isNotEmpty()) { + buffer.deleteRange(buffer.length - 1, buffer.length) + } else { + length = 0 + } + + val backtrackOnFail: Int = reader.position - buffer.length + length + if (!readDomainName(buffer.substring(length))) { + // go back to length location and restart search + reader.seek(backtrackOnFail) + readEnd(ReadEndState.InvalidUrl) + } + length = 0 + } else { + length = 0 + } + } else if (readScheme() && buffer.isNotEmpty()) { + hasScheme = true + length = buffer.length // set length to be right after the scheme + } else if (buffer.isNotEmpty() && reader.canReadChars(1)) { + // takes care of case like hi: + reader.goBack() // unread the ":" so readDomainName can take care of the port + buffer.deleteAt(buffer.length - 1) + if (!readDomainName(buffer.toString())) { + readEnd(ReadEndState.InvalidUrl) + } + } else { + readEnd(ReadEndState.InvalidUrl) + length = 0 + } + + return length + } + + /** + * Checks if the url is in the format: + * //google.com/static/js.js + * @return True if the url is in this format and was matched correctly. + */ + private fun readHtml5Root(): Boolean { + // end of input then go away. + if (reader.eof()) { + return false + } + + // read the next character. If its // then return true. + val curr = reader.read() + if (curr == '/') { + buffer.append(curr) + return true + } else { + // if its not //, then go back and reset by 1 character. + reader.goBack() + readEnd(ReadEndState.InvalidUrl) + } + return false + } + + /** + * Reads the scheme and allows returns true if the scheme is http(s?):// or ftp(s?):// + * @return True if the scheme was found, else false. + */ + private fun readScheme(): Boolean { + val originalLength: Int = buffer.length + var numSlashes = 0 + + while (!reader.eof()) { + val curr = reader.read() + + // if we match a slash, look for a second one. + if (curr == '/') { + buffer.append(curr) + if (numSlashes == 1) { + // return only if its an approved protocol. This can be expanded to allow others + val schemeStartIndex: Int = findValidSchemeStartIndex(buffer.toString()) + if (schemeStartIndex >= 0) { + buffer.deleteRange(0, schemeStartIndex) + currentUrlMarker.setIndex(UrlPart.SCHEME, 0) + return true + } else { + return false + } + } + numSlashes++ + } else if (curr == ' ') { + // if we find a space or end of input, then nothing found. + buffer.append(curr) + return false + } else if (curr == '[') { // if we're starting to see an ipv6 address + reader.goBack() // unread the '[', so that we can start looking for ipv6 + return false + } else if (originalLength > 0 && numSlashes == 0 && CharUtils.isAlpha(curr)) { + // If we had already read something before the : and we are matching regardless of slashes, assume it's a scheme + + // Add the slashes to the end of the scheme so it matches what's in the scheme list + val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString()) + if (schemeStartIndex >= 0) { + if (schemeStartIndex > 0) { + buffer.deleteRange(0, schemeStartIndex) + } + currentUrlMarker.setIndex(UrlPart.SCHEME, 0) + reader.goBack() + return true + } else { + reader.goBack() + return readUserPass(0) + } + // If this didn't match a defined scheme, continue processing as usual + } else if (originalLength > 0 || numSlashes > 0 || !CharUtils.isAlpha(curr)) { + // if it's not a character a-z or A-Z then assume we aren't matching scheme, but instead + // matching username and password. + reader.goBack() + return readUserPass(0) + } + } + + return false + } + + private fun findValidSchemeStartIndex(optionalScheme: String): Int { + val optionalSchemeLowercase = optionalScheme.lowercase() + return VALID_SCHEMES + .filter(optionalSchemeLowercase::endsWith) + .map(optionalSchemeLowercase::lastIndexOf) + .firstOrNull() ?: -1 + } + + private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int { + val optionalSchemeLowercase = optionalScheme.lowercase() + return VALID_SCHEMES_NO_SLASHES + .filter(optionalSchemeLowercase::endsWith) + .map(optionalSchemeLowercase::lastIndexOf) + .firstOrNull() ?: -1 + } + + /** + * Reads the input and looks for a username and password. + * Handles: + * http://username:password@... + * @param beginningOfUsername Index of the buffer of where the username began + * @return True if a valid username and password was found. + */ + private fun readUserPass(beginningOfUsername: Int): Boolean { + // The start of where we are. + val start: Int = buffer.length + + // keep looping until "done" + var done = false + + // if we had a dot in the input, then it might be a domain name and not a username and password. + var rollback = false + while (!done && !reader.eof()) { + val curr = reader.read() + + // if we hit this, then everything is ok and we are matching a domain name. + if (curr == '@') { + buffer.append(curr) + currentUrlMarker.setIndex(UrlPart.USERNAME_PASSWORD, beginningOfUsername) + return readDomainName("") + } else if (CharUtils.isDot(curr) || curr == '[') { + // everything is still ok, just remember that we found a dot or '[' in case we might need to backtrack + buffer.append(curr) + rollback = true + } else if (curr == '#' || curr == ' ' || curr == '/') { + // one of these characters indicates we are invalid state and should just return. + rollback = true + done = true + } else { + // all else, just append character assuming its ok so far. + buffer.append(curr) + } + } + + if (rollback) { + // got to here, so there is no username and password. (We didn't find a @) + val distance: Int = buffer.length - start + buffer.deleteRange(start, buffer.length) + + val currIndex: Int = max(reader.position - distance - (if (done) 1 else 0), 0) + reader.seek(currIndex) + + return false + } else { + return readEnd(ReadEndState.InvalidUrl) + } + } + + /** + * Try to read the current string as a domain name + * @param current The current string used. + * @return Whether the domain is valid or not. + */ + private fun readDomainName(current: String?): Boolean { + val hostIndex: Int = + if (current == null) buffer.length else buffer.length - current.length + + currentUrlMarker.setIndex(UrlPart.HOST, hostIndex) + + // create the domain name reader and specify the handler that will be called when a quote character + // or something is found. + val reader = DomainNameReader(reader, buffer, current) + + // Try to read the dns and act on the response. + val state = reader.readDomainName() + + isSingleLevelLabel = reader.labelCount() <= 1 && !reader.isIpV4 && !reader.isIpV6 + + return when (state) { + DomainNameReader.ReaderNextState.ValidDomainName -> { + readEnd(ReadEndState.ValidUrl) + } + + DomainNameReader.ReaderNextState.ReadFragment -> { + readFragment() + } + + DomainNameReader.ReaderNextState.ReadPath -> { + readPath() + } + + DomainNameReader.ReaderNextState.ReadPort -> { + readPort() + } + + DomainNameReader.ReaderNextState.ReadQueryString -> { + readQueryString() + } + + DomainNameReader.ReaderNextState.ReadUserPass -> { + val host: Int = currentUrlMarker.indexOf(UrlPart.HOST) + currentUrlMarker.unsetIndex(UrlPart.HOST) + readUserPass(host) + } + + else -> { + false + } + } + } + + /** + * Reads the fragments which is the part of the url starting with # + * @return If a valid fragment was read true, else false. + */ + private fun readFragment(): Boolean { + currentUrlMarker.setIndex(UrlPart.FRAGMENT, buffer.length - 1) + + while (!reader.eof()) { + val curr = reader.read() + + // if it's the end or space, then a valid url was read. + if (curr == ' ') { + return readEnd(ReadEndState.ValidUrl) + } else { + // otherwise keep appending. + buffer.append(curr) + } + } + + // if we are here, anything read is valid. + return readEnd(ReadEndState.ValidUrl) + } + + /** + * Try to read the query string. + * @return True if the query string was valid. + */ + private fun readQueryString(): Boolean { + currentUrlMarker.setIndex(UrlPart.QUERY, buffer.length - 1) + + while (!reader.eof()) { + val curr = reader.read() + + if (curr == '#') { // fragment + buffer.append(curr) + return readFragment() + } else if (curr == ' ') { + // end of query string + return readEnd(ReadEndState.ValidUrl) + } else { // all else add to buffer. + buffer.append(curr) + } + } + // a valid url was read. + return readEnd(ReadEndState.ValidUrl) + } + + /** + * Try to read the port of the url. + * @return True if a valid port was read. + */ + private fun readPort(): Boolean { + currentUrlMarker.setIndex(UrlPart.PORT, buffer.length) + // The length of the port read. + var portLen = 0 + while (!reader.eof()) { + // read the next one and remember the length + val curr = reader.read() + + // requires at least one number as port to + // better handle http://http:// + if (portLen == 0 && isSingleLevelLabel) { + if (!CharUtils.isNumeric(curr)) { + reader.goBack() + + currentUrlMarker.unsetIndex(UrlPart.PORT) + return readEnd(ReadEndState.InvalidUrl) + } + } + + portLen++ + + if (curr == ':') { + // rejects a second port + reader.goBack() + + currentUrlMarker.unsetIndex(UrlPart.PORT) + return readEnd(ReadEndState.InvalidUrl) + } else if (curr == '/') { + // continue to read path + buffer.append(curr) + return readPath() + } else if (curr == '?') { + // continue to read query string + buffer.append(curr) + return readQueryString() + } else if (curr == '#') { + // continue to read fragment. + buffer.append(curr) + return readFragment() + } else if (!CharUtils.isNumeric(curr)) { + // if we got here, then what we got so far is a valid url. don't append the current character. + reader.goBack() + + // no port found; it was something like google.com:hello.world + if (portLen == 1) { + // remove the ":" from the end. + buffer.deleteRange(buffer.length - 1, buffer.length) + } + currentUrlMarker.unsetIndex(UrlPart.PORT) + return readEnd(ReadEndState.ValidUrl) + } else { + // this is a valid character in the port string. + buffer.append(curr) + } + } + + // found a correct url + return readEnd(ReadEndState.ValidUrl) + } + + /** + * Tries to read the path + * @return True if the path is valid. + */ + private fun readPath(): Boolean { + currentUrlMarker.setIndex(UrlPart.PATH, buffer.length - 1) + + var endsOnASlash = true + + while (!reader.eof()) { + // read the next char + val curr = reader.read() + + if (curr == ' ') { + // if end of state and we got here, then the url is valid + // if it is not just a word/word + if ( + currentUrlMarker.hasScheme() || + currentUrlMarker.hasPort() || + currentUrlMarker.hasUsernamePassword() || + !isSingleLevelLabel || + endsOnASlash + ) { + return readEnd(ReadEndState.ValidUrl) + } else { + return readEnd(ReadEndState.InvalidUrl) + } + } + + // append the char + buffer.append(curr) + + // now see if we move to another state. + if (curr == '?') { + // if ? read query string + return readQueryString() + } else if (curr == '#') { + // if # read the fragment + return readFragment() + } + + endsOnASlash = curr == '/' + } + + // end of input then this url is good. + // if end of state and we got here, then the url is valid + // if it is not just a word/word + // no need to check for query and fragments + // here we accept urls that end in / + if ( + currentUrlMarker.hasScheme() || + currentUrlMarker.hasPort() || + currentUrlMarker.hasUsernamePassword() || + !isSingleLevelLabel || + endsOnASlash + ) { + return readEnd(ReadEndState.ValidUrl) + } else { + return readEnd(ReadEndState.InvalidUrl) + } + } + + /** + * The url has been read to here. Remember the url if its valid, and reset state. + * @param state The state indicating if this url is valid. If its valid it will be added to the list of urls. + * @return True if the url was valid. + */ + private fun readEnd(state: ReadEndState?): Boolean { + // if the url is valid and greater then 0 + if (state == ReadEndState.ValidUrl && buffer.isNotEmpty()) { + var url = buffer.toString() + if (url.lastOrNull() in CANNOT_END_URLS_WITH) url = url.dropLast(1) + urlList.add(currentUrlMarker.createUrl(url)) + } + + // clear out the buffer. + buffer.clear() + + // reset the state of internal objects. + hasScheme = false + isSingleLevelLabel = false + if (currentUrlMarker.hasChanged) { + currentUrlMarker = UrlMarker() + } + + // return true if valid. + return state == ReadEndState.ValidUrl + } + + companion object { + val VALID_SCHEMES_NO_SLASHES: List = + listOf( + "http:", + "https:", + "ftp:", + "ftps:", + "ws:", + "wss:", + "nostr:", + "blossom:", + ) + + val VALID_SCHEMES = + VALID_SCHEMES_NO_SLASHES.map { + "$it//" + } + + val CANNOT_BEGIN_URLS_WITH = + setOf( + ',', + '.', + ';', + '?', + '!', + ')', + '}', + '(', + '{', + '\u3002', + '\uFF0E', + '\uFF61', + ) + + val CANNOT_END_URLS_WITH = + setOf( + ',', + '.', + ';', + '?', + '!', + ':', + ')', + '}', + '(', + '{', + '\u3002', + '\uFF0E', + '\uFF61', + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt new file mode 100644 index 000000000..4173f9e22 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt @@ -0,0 +1,103 @@ +/* + * 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.nip44Encryption + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * NIP-44v2 tests verifying conversation key derivation, ECDH symmetry, + * and encrypt/decrypt round-trips on JVM Desktop. + */ +class Nip44v2JvmTest { + @Test + fun conversationKeyFromSpecVector1() { + val nip44v2 = Nip44v2() + val convKey = + nip44v2.getConversationKey( + "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268".hexToByteArray(), + "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133".hexToByteArray(), + ) + assertEquals( + "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1", + convKey.toHexKey(), + ) + } + + @Test + fun conversationKeyFromSpecVector2() { + val nip44v2 = Nip44v2() + val convKey = + nip44v2.getConversationKey( + "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e".hexToByteArray(), + "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800".hexToByteArray(), + ) + assertEquals( + "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b", + convKey.toHexKey(), + ) + } + + @Test + fun conversationKeyIsSymmetric() { + val nip44v2 = Nip44v2() + val privA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() + val privB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray() + val pubA = KeyPair(privA).pubKey + val pubB = KeyPair(privB).pubKey + + val convAB = nip44v2.getConversationKey(privA, pubB) + val convBA = nip44v2.getConversationKey(privB, pubA) + + assertEquals(convAB.toHexKey(), convBA.toHexKey()) + } + + @Test + fun encryptDecryptRoundTrip() { + val nip44v2 = Nip44v2() + val privA = "0000000000000000000000000000000000000000000000000000000000000001".hexToByteArray() + val privB = "0000000000000000000000000000000000000000000000000000000000000002".hexToByteArray() + val pubA = KeyPair(privA).pubKey + val pubB = KeyPair(privB).pubKey + + val encrypted = nip44v2.encrypt("hello world", privA, pubB) + val decrypted = nip44v2.decrypt(encrypted, privB, pubA) + assertEquals("hello world", decrypted) + } + + @Test + fun crossKeyPairDecrypt() { + val nip44v2 = Nip44v2() + val ephemeralPriv = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".hexToByteArray() + val signerPriv = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".hexToByteArray() + val ephemeralPub = KeyPair(ephemeralPriv).pubKey + val signerPub = KeyPair(signerPriv).pubKey + + val request = """{"id":"test","method":"connect","params":["deadbeef"]}""" + val encryptedRequest = nip44v2.encrypt(request, ephemeralPriv, signerPub) + + val decryptedRequest = nip44v2.decrypt(encryptedRequest, signerPriv, ephemeralPub) + assertEquals(request, decryptedRequest) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEventTest.kt new file mode 100644 index 000000000..98fb7f01f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEventTest.kt @@ -0,0 +1,152 @@ +/* + * 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.nip46RemoteSigner + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for NostrConnectEvent pure logic (canDecrypt, talkingWith, verifiedRecipientPubKey). + * + * Crypto-dependent tests (create + decrypt roundtrip) live in androidDeviceTest/Nip46Test.kt + * because NIP-44 encryption requires lazysodium which is only available on Android/device tests. + */ +class NostrConnectEventTest { + private val senderKey = NostrSignerInternal(KeyPair()) + private val recipientKey = NostrSignerInternal(KeyPair()) + private val thirdPartyKey = NostrSignerInternal(KeyPair()) + + /** Construct a NostrConnectEvent with known pubKey and p-tag, no real crypto needed */ + private fun buildEvent( + authorPubKey: String, + recipientPubKey: String, + ) = NostrConnectEvent( + id = "a".repeat(64), + pubKey = authorPubKey, + createdAt = 1L, + tags = arrayOf(arrayOf("p", recipientPubKey)), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + + // --- canDecrypt --- + + @Test + fun canDecryptAsSender() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertTrue(event.canDecrypt(senderKey)) + } + + @Test + fun canDecryptAsRecipient() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertTrue(event.canDecrypt(recipientKey)) + } + + @Test + fun canDecryptUnauthorizedReturnsFalse() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertFalse(event.canDecrypt(thirdPartyKey)) + } + + // --- talkingWith --- + + @Test + fun talkingWithAsSenderReturnsRecipient() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(recipientKey.pubKey, event.talkingWith(senderKey.pubKey)) + } + + @Test + fun talkingWithAsRecipientReturnsSender() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(senderKey.pubKey, event.talkingWith(recipientKey.pubKey)) + } + + @Test + fun talkingWithUnknownReturnsSender() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + // When oneSideHex doesn't match pubKey, returns pubKey (sender) + assertEquals(senderKey.pubKey, event.talkingWith(thirdPartyKey.pubKey)) + } + + // --- verifiedRecipientPubKey --- + + @Test + fun verifiedRecipientPubKeyWithValidHex() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(recipientKey.pubKey, event.verifiedRecipientPubKey()) + } + + @Test + fun verifiedRecipientPubKeyWithInvalidHex() { + val event = + NostrConnectEvent( + id = "a".repeat(64), + pubKey = senderKey.pubKey, + createdAt = 1L, + tags = arrayOf(arrayOf("p", "not-hex!")), + content = "encrypted", + sig = "b".repeat(128), + ) + assertNull(event.verifiedRecipientPubKey()) + } + + @Test + fun verifiedRecipientPubKeyWithNoPTag() { + val event = + NostrConnectEvent( + id = "a".repeat(64), + pubKey = senderKey.pubKey, + createdAt = 1L, + tags = emptyArray(), + content = "encrypted", + sig = "b".repeat(128), + ) + assertNull(event.verifiedRecipientPubKey()) + } + + // --- Kind --- + + @Test + fun kindIs24133() { + assertEquals(24133, NostrConnectEvent.KIND) + } + + @Test + fun eventHasCorrectKind() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(24133, event.kind) + } + + // --- isContentEncoded --- + + @Test + fun isContentEncodedReturnsTrue() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertTrue(event.isContentEncoded()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt new file mode 100644 index 000000000..77a45f5a5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt @@ -0,0 +1,95 @@ +/* + * 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.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ConvertExceptionsTest { + private val remote = + NostrSignerRemote.fromBunkerUri( + "bunker://${"a".repeat(64)}?relay=wss://r.com", + NostrSignerInternal(KeyPair()), + EmptyNostrClient, + ) + + @Test + fun successfulReturnsBug() { + val result = SignerResult.RequestAddressed.Successful(PingResult("pong")) + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("bug")) + } + + @Test + fun rejectedReturnsManuallyUnauthorized() { + val result = SignerResult.RequestAddressed.Rejected() + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + } + + @Test + fun timedOutReturnsTimedOutException() { + val result = SignerResult.RequestAddressed.TimedOut() + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + } + + @Test + fun couldNotPerformReturnsCouldNotPerformException() { + val result = SignerResult.RequestAddressed.ReceivedButCouldNotPerform("custom msg") + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("custom msg")) + } + + @Test + fun couldNotParseReturnsIllegalState() { + val result = SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult("{bad}") + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("{bad}")) + } + + @Test + fun couldNotVerifyReturnsIllegalState() { + val event = + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1L, + kind = 1, + tags = emptyArray(), + content = "", + sig = "c".repeat(128), + ) + val result = SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("verify")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt new file mode 100644 index 000000000..75447e1be --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt @@ -0,0 +1,88 @@ +/* + * 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.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class FromBunkerUriTest { + private val signer = NostrSignerInternal(KeyPair()) + private val client = EmptyNostrClient + private val validHex = "a".repeat(64) + + @Test + fun validSingleRelay() { + val uri = "bunker://$validHex?relay=wss://relay.example.com" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals(1, remote.relays.size) + assertEquals(validHex, remote.remotePubkey) + assertNull(remote.secret) + } + + @Test + fun validMultipleRelays() { + val uri = "bunker://$validHex?relay=wss://a.com&relay=wss://b.com" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals(2, remote.relays.size) + } + + @Test + fun validWithSecret() { + val uri = "bunker://$validHex?relay=wss://r.com&secret=abc123" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals("abc123", remote.secret) + assertEquals(1, remote.relays.size) + } + + @Test + fun missingSchemeThrows() { + assertFailsWith { + NostrSignerRemote.fromBunkerUri("npub1abc", signer, client) + } + } + + @Test + fun invalidHexPubkeyThrows() { + assertFailsWith { + NostrSignerRemote.fromBunkerUri("bunker://notHex?relay=wss://r.com", signer, client) + } + } + + @Test + fun missingQueryParamsThrows() { + assertFailsWith { + NostrSignerRemote.fromBunkerUri("bunker://$validHex", signer, client) + } + } + + @Test + fun skipsMalformedParams() { + val uri = "bunker://$validHex?relay=wss://r.com&badparam" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals(1, remote.relays.size) + assertNull(remote.secret) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt new file mode 100644 index 000000000..95432f61f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -0,0 +1,269 @@ +/* + * 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.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * INostrClient that records openReqSubscription calls for verification. + * Used instead of mockk since commonTest doesn't have mockk. + */ +private class TrackingNostrClient : INostrClient { + data class SubscriptionRecord( + val subId: String, + val filters: Map>, + ) + + val subscriptions = mutableListOf() + val sentEvents = mutableListOf>>() + + override fun connectedRelaysFlow() = MutableStateFlow(emptySet()) + + override fun availableRelaysFlow() = MutableStateFlow(emptySet()) + + override fun connect() {} + + override fun disconnect() {} + + override fun reconnect( + onlyIfChanged: Boolean, + ignoreRetryDelays: Boolean, + ) {} + + override fun isActive() = false + + override fun renewFilters(relay: IRelayClient) {} + + override fun openReqSubscription( + subId: String, + filters: Map>, + listener: IRequestListener?, + ) { + subscriptions.add(SubscriptionRecord(subId, filters)) + } + + override fun queryCount( + subId: String, + filters: Map>, + ) {} + + override fun close(subId: String) {} + + override fun send( + event: Event, + relayList: Set, + ) { + sentEvents.add(event to relayList) + } + + override fun subscribe(listener: IRelayClientListener) {} + + override fun unsubscribe(listener: IRelayClientListener) {} + + override fun getReqFiltersOrNull(subId: String): Map>? = null + + override fun getCountFiltersOrNull(subId: String): Map>? = null + + override fun activeRequests(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() +} + +/** + * Tests verifying NIP-46 relay isolation at the NostrSignerRemote level. + * Ensures subscription filters only target bunker relays and contain + * the correct kind + p-tag. + */ +class NostrSignerRemoteIsolationTest { + private val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/") + private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/") + private val validHex = "a".repeat(64) + + @Test + fun subscriptionFilterTargetsOnlyBunkerRelays() { + val trackingClient = TrackingNostrClient() + val ephemeralSigner = NostrSignerInternal(KeyPair()) + + // Construction triggers client.req() which calls openReqSubscription + NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = validHex, + relays = setOf(bunkerRelay), + client = trackingClient, + ) + + // Verify subscription was opened + assertTrue( + trackingClient.subscriptions.isNotEmpty(), + "No subscription opened on construction", + ) + + // Verify ALL filters only target the bunker relay + trackingClient.subscriptions.forEach { record -> + assertTrue( + record.filters.keys.all { it == bunkerRelay }, + "Filter contains non-bunker relay: ${record.filters.keys}", + ) + assertTrue( + generalRelay !in record.filters.keys, + "General relay leaked into NIP-46 subscription", + ) + } + } + + @Test + fun subscriptionFilterContainsCorrectKindAndPTag() { + val trackingClient = TrackingNostrClient() + val ephemeralKeyPair = KeyPair() + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + + NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = validHex, + relays = setOf(bunkerRelay), + client = trackingClient, + ) + + val allFilters = + trackingClient.subscriptions.flatMap { it.filters.values.flatten() } + assertTrue(allFilters.isNotEmpty(), "No filters captured") + + allFilters.forEach { filter -> + // Must filter for NIP-46 event kind (24133) + assertTrue( + filter.kinds?.contains(NostrConnectEvent.KIND) == true, + "Filter missing kind ${NostrConnectEvent.KIND}, got: ${filter.kinds}", + ) + + // Must filter for our ephemeral pubkey in p-tag + val pTags = filter.tags?.get("p") + assertNotNull(pTags, "Filter missing p-tag") + assertTrue( + pTags.contains(ephemeralSigner.pubKey), + "Filter p-tag doesn't contain ephemeral pubkey", + ) + } + } + + @Test + fun multipleRelaysAllIncludedInFilter() { + val trackingClient = TrackingNostrClient() + val relay1 = NormalizedRelayUrl("wss://relay1.nsec.app/") + val relay2 = NormalizedRelayUrl("wss://relay2.nsec.app/") + + NostrSignerRemote( + signer = NostrSignerInternal(KeyPair()), + remotePubkey = validHex, + relays = setOf(relay1, relay2), + client = trackingClient, + ) + + assertTrue(trackingClient.subscriptions.isNotEmpty()) + val allRelayKeys = + trackingClient.subscriptions.flatMap { it.filters.keys }.toSet() + + assertTrue(relay1 in allRelayKeys, "Missing relay1 in subscription") + assertTrue(relay2 in allRelayKeys, "Missing relay2 in subscription") + // General relay should NOT be present + assertTrue( + generalRelay !in allRelayKeys, + "General relay leaked into multi-relay subscription", + ) + } + + /** + * Verify subscription filter does NOT use a `since` timestamp, + * matching upstream behavior (PR #1789 removed it). + */ + @Test + fun subscriptionFilterHasNoSinceTimestamp() { + val trackingClient = TrackingNostrClient() + + NostrSignerRemote( + signer = NostrSignerInternal(KeyPair()), + remotePubkey = validHex, + relays = setOf(bunkerRelay), + client = trackingClient, + ) + + val allFilters = + trackingClient.subscriptions.flatMap { it.filters.values.flatten() } + assertTrue(allFilters.isNotEmpty()) + + allFilters.forEach { filter -> + assertTrue( + filter.since == null, + "Filter should not have a 'since' timestamp", + ) + } + } + + @Test + fun fromBunkerUriPreservesRelayIsolation() { + val trackingClient = TrackingNostrClient() + val ephemeralSigner = NostrSignerInternal(KeyPair()) + + val remote = + NostrSignerRemote.fromBunkerUri( + "bunker://$validHex?relay=wss://relay.nsec.app", + ephemeralSigner, + trackingClient, + ) + + // Verify relay set matches URI + assertEquals(1, remote.relays.size) + val parsedRelay = remote.relays.first() + assertTrue( + parsedRelay.url.contains("relay.nsec.app"), + "Parsed relay doesn't match URI: ${parsedRelay.url}", + ) + + // Verify subscription was opened targeting ONLY the bunker relay + assertTrue(trackingClient.subscriptions.isNotEmpty()) + trackingClient.subscriptions.forEach { record -> + assertTrue( + record.filters.keys.all { it in remote.relays }, + "fromBunkerUri subscription targets wrong relays: ${record.filters.keys}", + ) + // General relay must NOT appear + assertTrue( + generalRelay !in record.filters.keys, + "General relay leaked into fromBunkerUri subscription", + ) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt new file mode 100644 index 000000000..af0e62c21 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt @@ -0,0 +1,284 @@ +/* + * 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.nip46RemoteSigner.signer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ResponseParserTest { + // --- ConnectResponse --- + // connect never returns a pubkey — it's "ack" or the secret string. + // Tests use base BunkerResponse to match production deserialization behavior. + + @Test + fun connectParseAck() { + val response = BunkerResponse("req-0", "ack", null) + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseSecret() { + val response = BunkerResponse("req-0", "my-secret-token", null) + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseAlreadyConnected() { + val response = BunkerResponse("req-0", null, "already connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseAlreadyConnectedCaseInsensitive() { + val response = BunkerResponse("req-0", null, "Already Connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseRealError() { + val response = BunkerResponse("req-0", null, "unauthorized") + val result = ConnectResponse.parse(response) + assertIs>(result) + } + + @Test + fun connectParseNoResultNoError() { + val response = BunkerResponse("req-0", null, null) + val result = ConnectResponse.parse(response) + assertIs>(result) + } + + // --- PingResponse --- + + @Test + fun pingParsePong() { + val response = BunkerResponsePong("req-1") + val result = PingResponse.parse(response) + assertIs>(result) + assertEquals("req-1", result.result.pong) + } + + @Test + fun pingParseError() { + val response = BunkerResponseError("req-1", "not allowed") + val result = PingResponse.parse(response) + assertIs>(result) + } + + @Test + fun pingParseUnexpected() { + val response = BunkerResponseAck("req-1") + val result = PingResponse.parse(response) + assertIs>(result) + } + + // --- PubKeyResponse --- + + @Test + fun pubKeyParseSuccess() { + val hex = "a".repeat(64) + val response = BunkerResponsePublicKey("req-2", hex) + val result = PubKeyResponse.parse(response) + assertIs>(result) + assertEquals(hex, result.result.pubkey) + } + + @Test + fun pubKeyParseError() { + val response = BunkerResponseError("req-2", "denied") + val result = PubKeyResponse.parse(response) + assertIs>(result) + } + + @Test + fun pubKeyParseUnexpected() { + val response = BunkerResponseAck("req-2") + val result = PubKeyResponse.parse(response) + assertIs>(result) + } + + // --- SignResponse --- + + @Test + fun signParseValidEvent() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val event: Event = + signer.sign( + createdAt = 1234L, + kind = 1, + tags = emptyArray(), + content = "hello", + ) + val response = BunkerResponseEvent("req-3", event) + val result = SignResponse.parse(response) + assertIs>(result) + assertEquals(event.id, result.result.event.id) + } + + @Test + fun signParseInvalidSignature() { + val event = + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = "c".repeat(128), + ) + val response = BunkerResponseEvent("req-3", event) + val result = SignResponse.parse(response) + assertIs>(result) + } + + @Test + fun signParseError() { + val response = BunkerResponseError("req-3", "denied") + val result = SignResponse.parse(response) + assertIs>(result) + } + + @Test + fun signParseUnexpected() { + val response = BunkerResponseAck("req-3") + val result = SignResponse.parse(response) + assertIs>(result) + } + + // --- Nip04EncryptResponse --- + + @Test + fun nip04EncryptParseSuccess() { + val response = BunkerResponseEncrypt("req-4", "ciphertext-data") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + assertEquals("ciphertext-data", result.result.ciphertext) + } + + @Test + fun nip04EncryptParseError() { + val response = BunkerResponseError("req-4", "fail") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip04EncryptParseUnexpected() { + val response = BunkerResponsePong("req-4") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip04DecryptResponse --- + + @Test + fun nip04DecryptParseSuccess() { + val response = BunkerResponseDecrypt("req-5", "plain-text") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + assertEquals("plain-text", result.result.plaintext) + } + + @Test + fun nip04DecryptParseError() { + val response = BunkerResponseError("req-5", "fail") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip04DecryptParseUnexpected() { + val response = BunkerResponsePong("req-5") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip44EncryptResponse --- + + @Test + fun nip44EncryptParseSuccess() { + val response = BunkerResponseEncrypt("req-6", "nip44-cipher") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + assertEquals("nip44-cipher", result.result.ciphertext) + } + + @Test + fun nip44EncryptParseError() { + val response = BunkerResponseError("req-6", "fail") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip44EncryptParseUnexpected() { + val response = BunkerResponseAck("req-6") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip44DecryptResponse --- + + @Test + fun nip44DecryptParseSuccess() { + val response = BunkerResponseDecrypt("req-7", "nip44-plain") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + assertEquals("nip44-plain", result.result.plaintext) + } + + @Test + fun nip44DecryptParseError() { + val response = BunkerResponseError("req-7", "fail") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip44DecryptParseUnexpected() { + val response = BunkerResponseAck("req-7") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt index 678a1ed34..a52602ca6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip64Chess +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -83,7 +84,7 @@ class ChessGameEventTest { sig = "test_sig", ) - assertEquals(customAltText, testEvent.altText(), "Alt text should be extractable from tags") + assertEquals(customAltText, testEvent.alt(), "Alt text should be extractable from tags") } @Test @@ -98,7 +99,7 @@ class ChessGameEventTest { sig = "test_sig", ) - assertEquals(null, testEvent.altText(), "Should return null when no alt tag present") + assertEquals(null, testEvent.alt(), "Should return null when no alt tag present") } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt new file mode 100644 index 000000000..68be8cdb6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt @@ -0,0 +1,134 @@ +/* + * 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.nipB7Blossom + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BlossomUriTest { + private val sha256 = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + private val authorPubkey = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e" + + @Test + fun parsesMinimalUri() { + val uri = "blossom:$sha256.jpg" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(sha256, result.sha256) + assertEquals("jpg", result.extension) + assertTrue(result.servers.isEmpty()) + assertTrue(result.authors.isEmpty()) + assertNull(result.size) + } + + @Test + fun parsesFullUri() { + val uri = + "blossom:$sha256.mp4" + + "?xs=https%3A%2F%2Fcdn.example.com" + + "&xs=https%3A%2F%2Fbackup.example.com" + + "&as=$authorPubkey" + + "&sz=1048576" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(sha256, result.sha256) + assertEquals("mp4", result.extension) + assertEquals(listOf("https://cdn.example.com", "https://backup.example.com"), result.servers) + assertEquals(listOf(authorPubkey), result.authors) + assertEquals(1048576L, result.size) + } + + @Test + fun parsesUriWithUnencodedServerUrls() { + // xs values with unencoded colons/slashes should still parse + val uri = "blossom:$sha256.bin?xs=https://cdn.example.com&sz=512" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(listOf("https://cdn.example.com"), result.servers) + assertEquals(512L, result.size) + } + + @Test + fun defaultsExtensionToBinWhenMissing() { + val uri = "blossom:$sha256" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals("bin", result.extension) + } + + @Test + fun isCaseInsensitiveForScheme() { + val result = BlossomUri.parse("BLOSSOM:$sha256.png") + assertNotNull(result) + assertEquals(sha256, result.sha256) + } + + @Test + fun normalisesUpperCaseSha256ToLowercase() { + val result = BlossomUri.parse("blossom:${sha256.uppercase()}.jpg") + assertNotNull(result) + assertEquals(sha256, result.sha256) + } + + @Test + fun returnsNullForWrongScheme() { + assertNull(BlossomUri.parse("magnet:?xt=urn:btih:$sha256")) + assertNull(BlossomUri.parse("https://example.com/$sha256")) + } + + @Test + fun returnsNullForMalformedSha256() { + assertNull(BlossomUri.parse("blossom:tooshort.jpg")) + assertNull(BlossomUri.parse("blossom:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg.jpg")) + } + + @Test + fun roundTrip() { + val original = + BlossomUri( + sha256 = sha256, + extension = "jpg", + servers = listOf("https://cdn.example.com", "https://backup.example.com"), + authors = listOf(authorPubkey), + size = 204800L, + ) + val reparsed = BlossomUri.parse(original.toUriString()) + + assertEquals(original, reparsed) + } + + @Test + fun handlesMultipleAuthors() { + val author2 = "b8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7f" + val uri = "blossom:$sha256.jpg?as=$authorPubkey&as=$author2" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(listOf(authorPubkey, author2), result.authors) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt new file mode 100644 index 000000000..660759b16 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt @@ -0,0 +1,321 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals + +class UrlMarkerTest { + fun testUrlMarker( + testString: String, + scheme: String?, + username: String?, + password: String?, + host: String?, + port: Int, + path: String?, + query: String?, + fragment: String?, + indices: IntArray, + ) { + val urlMarker = UrlMarker() + urlMarker.setIndices(indices) + val url = urlMarker.createUrl(testString) + assertEquals(url.host, host, "host, " + testString) + assertEquals(url.path, path, "path, " + testString) + assertEquals(url.scheme, scheme, "scheme, " + testString) + assertEquals(url.username, username, "username, " + testString) + assertEquals(url.password, password, "password, " + testString) + assertEquals(url.port, port, "port, " + testString) + assertEquals(url.query, query, "query, " + testString) + assertEquals(url.fragment, fragment, "fragment, " + testString) + } + + @Test + fun test1() = + testUrlMarker( + "hello@hello.com", + "https", + "hello", + "", + "hello.com", + 443, + "/", + "", + "", + intArrayOf(-1, 0, 6, -1, -1, -1, -1), + ) + + @Test + fun test2() = + testUrlMarker( + "http://hello@hello.com", + "http", + "hello", + "", + "hello.com", + 80, + "/", + "", + "", + intArrayOf(0, 7, 13, -1, -1, -1, -1), + ) + + @Test + fun test3() = + testUrlMarker( + "hello@hello.com", + "https", + "hello", + "", + "hello.com", + 443, + "/", + "", + "", + intArrayOf(-1, 0, 6, -1, -1, -1, -1), + ) + + @Test + fun test4() = + testUrlMarker( + "https://user@google.com/h?hello=w#abc", + "https", + "user", + "", + "google.com", + 443, + "/h", + "?hello=w", + "#abc", + intArrayOf(0, 8, 13, -1, 23, 25, 33), + ) + + @Test + fun test5() = + testUrlMarker( + "www.booopp.com:20#fa", + "https", + "", + "", + "www.booopp.com", + 20, + "/", + "", + "#fa", + intArrayOf(-1, -1, 0, 15, -1, -1, 17), + ) + + @Test + fun test6() = + testUrlMarker( + "www.yahooo.com:20?fff#aa", + "https", + "", + "", + "www.yahooo.com", + 20, + "/", + "?fff", + "#aa", + intArrayOf(-1, -1, 0, 15, -1, 17, 21), + ) + + @Test + fun test7() = + testUrlMarker( + "www.google.com#fa", + "https", + "", + "", + "www.google.com", + 443, + "/", + "", + "#fa", + intArrayOf(-1, -1, 0, -1, -1, -1, 14), + ) + + @Test + fun test8() = + testUrlMarker( + "www.google.com?3fd#fa", + "https", + "", + "", + "www.google.com", + 443, + "/", + "?3fd", + "#fa", + intArrayOf(-1, -1, 0, -1, -1, 14, 18), + ) + + @Test + fun test9() = + testUrlMarker( + "//www.google.com/", + "", + "", + "", + "www.google.com", + -1, + "/", + "", + "", + intArrayOf(-1, -1, 2, -1, 16, -1, -1), + ) + + @Test + fun test10() = + testUrlMarker( + "http://www.google.com/", + "http", + "", + "", + "www.google.com", + 80, + "/", + "", + "", + intArrayOf(0, -1, 7, -1, 21, -1, -1), + ) + + @Test + fun test11() = + testUrlMarker( + "ftp://whosdere:me@google.com/", + "ftp", + "whosdere", + "me", + "google.com", + 21, + "/", + "", + "", + intArrayOf(0, 6, 18, -1, 28, -1, -1), + ) + + @Test + fun test12() = + testUrlMarker( + "ono:doope@fb.net:9090/dhdh", + "https", + "ono", + "doope", + "fb.net", + 9090, + "/dhdh", + "", + "", + intArrayOf(-1, 0, 10, 17, 21, -1, -1), + ) + + @Test + fun test13() = + testUrlMarker( + "ono:a@fboo.com:90/dhdh/@1234", + "https", + "ono", + "a", + "fboo.com", + 90, + "/dhdh/@1234", + "", + "", + intArrayOf(-1, 0, 6, 15, 17, -1, -1), + ) + + @Test + fun test14() = + testUrlMarker( + "fbeoo.net:990/dhdeh/@1234", + "https", + "", + "", + "fbeoo.net", + 990, + "/dhdeh/@1234", + "", + "", + intArrayOf(-1, -1, 0, 10, 13, -1, -1), + ) + + @Test + fun test15() = + testUrlMarker( + "fbeoo:@boop.com/dhdeh/@1234?aj=r", + "https", + "fbeoo", + "", + "boop.com", + 443, + "/dhdeh/@1234", + "?aj=r", + "", + intArrayOf(-1, 0, 7, -1, 15, 27, -1), + ) + + @Test + fun test16() = + testUrlMarker( + "bloop:@noooo.com/doop/@1234", + "https", + "bloop", + "", + "noooo.com", + 443, + "/doop/@1234", + "", + "", + intArrayOf(-1, 0, 7, -1, 16, -1, -1), + ) + + @Test + fun test17() = + testUrlMarker( + "bah.com/lala/@1234/@dfd@df?@dsf#ono", + "https", + "", + "", + "bah.com", + 443, + "/lala/@1234/@dfd@df", + "?@dsf", + "#ono", + intArrayOf(-1, -1, 0, -1, 7, 26, 31), + ) + + @Test + fun test18() = + testUrlMarker( + "https://dewd:dood@www.google.com:20/?why=is&this=test#?@Sdsf", + "https", + "dewd", + "dood", + "www.google.com", + 20, + "/", + "?why=is&this=test", + "#?@Sdsf", + intArrayOf(0, 8, 18, 33, 35, 36, 53), + ) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/CharUtilsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/CharUtilsTest.kt new file mode 100644 index 000000000..47489590a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/CharUtilsTest.kt @@ -0,0 +1,121 @@ +/* + * 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.detection + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CharUtilsTest { + @Test + fun testCharUtilsIsHex() { + val arr = charArrayOf('a', 'A', '0', '9') + for (a in arr) { + assertTrue(CharUtils.isHex(a)) + } + + val arr2 = charArrayOf('~', ';', 'Z', 'g') + for (a in arr2) { + assertFalse(CharUtils.isHex(a)) + } + } + + @Test + fun testCharUtilsIsNumeric() { + val arr = charArrayOf('0', '4', '6', '9') + for (a in arr) { + assertTrue(CharUtils.isNumeric(a)) + } + + val arr2 = charArrayOf('a', '~', 'A', 0.toChar()) + for (a in arr2) { + assertFalse(CharUtils.isNumeric(a)) + } + } + + @Test + fun testCharUtilsIsAlpha() { + val arr = charArrayOf('a', 'Z', 'f', 'X') + for (a in arr) { + assertTrue(CharUtils.isAlpha(a)) + } + + val arr2 = charArrayOf('0', '9', '[', '~') + for (a in arr2) { + assertFalse(CharUtils.isAlpha(a)) + } + } + + @Test + fun testCharUtilsIsAlphaNumeric() { + val arr = charArrayOf('a', 'G', '3', '9') + for (a in arr) { + assertTrue(CharUtils.isAlphaNumeric(a)) + } + + val arr2 = charArrayOf('~', '-', '_', '\n') + for (a in arr2) { + assertFalse(CharUtils.isAlphaNumeric(a)) + } + } + + @Test + fun testCharUtilsIsUnreserved() { + val arr = charArrayOf('-', '.', 'a', '9', 'Z', '_', 'f') + for (a in arr) { + assertTrue(CharUtils.isUnreserved(a)) + } + + val arr2 = charArrayOf(' ', '!', '(', '\n') + for (a in arr2) { + assertFalse(CharUtils.isUnreserved(a)) + } + } + + @Test + fun testSplitByDot() { + val stringsToSplit = + listOf( + "192.168.1.1", + "..", + "192%2e168%2e1%2e1", + "asdf", + "192.39%2e1%2E1", + "as\uFF61awe.a3r23.lkajsf0ijr....", + "%2e%2easdf", + "sdoijf%2e", + "ksjdfh.asdfkj.we%2", + "0xc0%2e0x00%2e0x02%2e0xeb", + "", + ) + + val regex = "[\\.\u3002\uFF0E\uFF61]|%2e|%2E".toRegex() + + stringsToSplit.forEach { stringToSplit -> + assertContentEquals( + stringToSplit.split(regex), + CharUtils.splitByDot(stringToSplit), + "Splitting $stringToSplit", + ) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/InputTextReaderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/InputTextReaderTest.kt new file mode 100644 index 000000000..4ae96a7d8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/InputTextReaderTest.kt @@ -0,0 +1,76 @@ +/* + * 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.detection + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class InputTextReaderTest { + @Test + fun testSimpleRead() { + val reader = InputTextReader(CONTENT) + for (i in 0..google.com", + "http://www.google.com/\">google.com", + ) + } + + @Test + fun testNonStandardDots() { + runTest( + "www\u3002google\u3002com username:password@www\uFF0Eyahoo\uFF0Ecom http://www\uFF61facebook\uFF61com http://192\u3002168\uFF0E0\uFF611/", + "www\u3002google\u3002com", + "username:password@www\uFF0Eyahoo\uFF0Ecom", + "http://www\uFF61facebook\uFF61com", + "http://192\u3002168\uFF0E0\uFF611/", + ) + } + + @Test + fun testNonStandardDotsBacktracking() { + runTest("\u9053 \u83dc\u3002\u3002\u3002\u3002") + } + + @Test + fun testBacktrackingStrangeFormats() { + runTest( + "http:http:http://www.google.com www.www:yahoo.com yahoo.com.br hello.hello..hello.com", + "http://www.google.com", + "www.www", + "yahoo.com", + "yahoo.com.br", + "hello.hello", + "hello.com", + ) + } + + @Test + fun testBacktrackingUsernamePassword() { + runTest("check out my url:www.google.com", "www.google.com") + runTest("check out my url:www.google.com ", "www.google.com") + } + + @Test + fun testBacktrackingEmptyDomainName() { + runTest("check out my http:///hello") + runTest("check out my http://./hello") + } + + @Test + fun testDoubleScheme() { + runTest("http://http://") + runTest("hello http://http://") + } + + @Test + fun testMultipleSchemes() { + runTest("http://http://www.google.com", "http://www.google.com") + runTest( + "make sure it's right here http://http://www.google.com", + "http://www.google.com", + ) + runTest( + "http://http://http://www.google.com", + "http://www.google.com", + ) + runTest( + "make sure it's right here http://http://http://www.google.com", + "http://www.google.com", + ) + runTest( + "http://ftp://https://www.google.com", + "https://www.google.com", + ) + runTest( + "make sure its right here http://ftp://https://www.google.com", + "https://www.google.com", + ) + } + + @Test + fun testDottedHexIpAddress() { + runTest( + "http://0xc0.0x00.0xb2.0xEB", + "http://0xc0.0x00.0xb2.0xEB", + ) + runTest( + "http://0xc0.0x0.0xb2.0xEB", + "http://0xc0.0x0.0xb2.0xEB", + ) + runTest( + "http://0x000c0.0x00000.0xb2.0xEB", + "http://0x000c0.0x00000.0xb2.0xEB", + ) + runTest( + "http://0xc0.0x00.0xb2.0xEB/bobo", + "http://0xc0.0x00.0xb2.0xEB/bobo", + ) + runTest( + "ooh look i can find it in text http://0xc0.0x00.0xb2.0xEB/bobo like this", + "http://0xc0.0x00.0xb2.0xEB/bobo", + ) + runTest( + "noscheme look 0xc0.0x00.0xb2.0xEB/bobo", + "0xc0.0x00.0xb2.0xEB/bobo", + ) + runTest( + "no scheme 0xc0.0x00.0xb2.0xEB or path", + "0xc0.0x00.0xb2.0xEB", + ) + } + + @Test + fun testDottedOctalIpAddress() { + runTest( + "http://0301.0250.0002.0353", + "http://0301.0250.0002.0353", + ) + runTest( + "http://0301.0250.0002.0353/bobo", + "http://0301.0250.0002.0353/bobo", + ) + runTest("http://192.168.017.015/", "http://192.168.017.015/") + runTest( + "ooh look i can find it in text http://0301.0250.0002.0353/bobo like this", + "http://0301.0250.0002.0353/bobo", + ) + runTest( + "noscheme look 0301.0250.0002.0353/bobo", + "0301.0250.0002.0353/bobo", + ) + runTest( + "no scheme 0301.0250.0002.0353 or path", + "0301.0250.0002.0353", + ) + } + + @Test + fun testHexIpAddress() { + runTest("http://0xC00002EB/hello", "http://0xC00002EB/hello") + runTest( + "http://0xC00002EB.com/hello", + "http://0xC00002EB.com/hello", + ) + runTest( + "still look it up as a normal url http://0xC00002EXsB.com/hello", + "http://0xC00002EXsB.com/hello", + ) + runTest( + "ooh look i can find it in text http://0xC00002EB/bobo like this", + "http://0xC00002EB/bobo", + ) + runTest( + "browsers dont support this without a scheme look 0xC00002EB/bobo", + "0xC00002EB/bobo", + ) + runTest( + "browsers dont support this without a scheme look test/bobo", + ) + } + + @Test + fun testOctalIpAddress() { + runTest( + "http://030000001353/bobobo", + "http://030000001353/bobobo", + ) + runTest( + "ooh look i can find it in text http://030000001353/bobo like this", + "http://030000001353/bobo", + ) + runTest( + "browsers dont support this without a scheme look 030000001353/bobo", + "030000001353/bobo", + ) + runTest( + "browsers dont support this without a scheme look 1727123/bobo", + ) + } + + @Test + fun testUrlWithEmptyPort() { + runTest( + "http://wtfismyip.com:/foo.html", + "http://wtfismyip.com:/foo.html", + ) + runTest( + "http://wtfismyip.com://foo.html", + "http://wtfismyip.com://foo.html", + ) + runTest( + "make sure its right here http://wtfismyip.com://foo.html", + "http://wtfismyip.com://foo.html", + ) + } + + @Test + fun testUrlEncodedDot() { + runTest("hello www%2ewtfismyip%2ecom", "www%2ewtfismyip%2ecom") + runTest("hello wtfismyip%2ecom", "wtfismyip%2ecom") + runTest("http://wtfismyip%2ecom", "http://wtfismyip%2ecom") + runTest( + "make sure its right here http://wtfismyip%2ecom", + "http://wtfismyip%2ecom", + ) + } + + @Test + fun testUrlEncodedBadPath() { + runTest("%2ewtfismyip") + runTest("wtfismyip%2e") + runTest("wtfismyip%2ecom%2e", "wtfismyip%2ecom%2e") + runTest("wtfismyip%2ecom.", "wtfismyip%2ecom") + runTest("%2ewtfismyip%2ecom", "wtfismyip%2ecom") + } + + @Test + fun testDetectUrlEncoded() { + runTest( + "%77%77%77%2e%67%75%6d%62%6c%61%72%2e%63%6e", + "%77%77%77%2e%67%75%6d%62%6c%61%72%2e%63%6e", + ) + runTest( + " asdf %77%77%77%2e%67%75%6d%62%6c%61%72%2e%63%6e", + "%77%77%77%2e%67%75%6d%62%6c%61%72%2e%63%6e", + ) + runTest( + "%77%77%77%2e%67%75%6d%62%6c%61%72%2e%63%6e%2e", + "%77%77%77%2e%67%75%6d%62%6c%61%72%2e%63%6e%2e", + ) + } + + @Test + fun testIncompleteIpAddresses() { + runTest("hello 10...") + runTest("hello 10...1") + runTest("hello 10..1.") + runTest("hello 10..1.1") + runTest("hello 10.1..1") + runTest("hello 10.1.1.") + runTest("hello .192..") + runTest("hello .192..1") + runTest("hello .192.1.") + runTest("hello .192.1.1") + runTest("hello ..3.") + runTest("hello ..3.1") + runTest("hello ...1") + } + + @Test + fun testIPv4EncodedDot() { + runTest("hello 192%2e168%2e1%2e1", "192%2e168%2e1%2e1") + runTest( + "hello 192.168%2e1%2e1/lalala", + "192.168%2e1%2e1/lalala", + ) + } + + @Test + fun testIPv4HexEncodedDot() { + runTest( + "hello 0xee%2e0xbb%2e0x1%2e0x1", + "0xee%2e0xbb%2e0x1%2e0x1", + ) + runTest( + "hello 0xee%2e0xbb.0x1%2e0x1/lalala", + "0xee%2e0xbb.0x1%2e0x1/lalala", + ) + } + + @Test + fun testIpv6BadWithGoodUrls() { + runTest("[:::] [::] [bacd::]", "[::]", "[bacd::]") + runTest("[:0][::]", "[::]") + runTest("[:0:][::afaf]", "[::afaf]") + runTest( + "::] [fe80:aaaa:aaaa:aaaa::]", + "[fe80:aaaa:aaaa:aaaa::]", + ) + runTest( + "fe80:22:]3123:[adf] [fe80:aaaa:aaaa:aaaa::]", + "[fe80:aaaa:aaaa:aaaa::]", + ) + runTest("[][123[][ae][fae][de][:a][d]aef:E][f") + runTest("[][][]2[d][]][]]]:d][[[:d[e][aee:]af:") + } + + @Test + fun testIpv6BadWithGoodUrlsEmbedded() { + runTest( + "[fe80:aaaa:aaaa:aaaa:[::]3dd0:7f8e:57b7:34d5f]", + "[::]", + ) + runTest("[b[::7f8e]:55]akjef[::]", "[::7f8e]:55", "[::]") + runTest( + "[bcad::kkkk:aaaa:3dd0[::7f8e]:57b7:34d5]akjef[::]", + "aaaa:3", + "[::7f8e]:57", + "b7:34", + "[::]", + ) + } + + @Test + fun testIpv6BadWithGoodUrlsWeirder() { + runTest("[:[::]", "[::]") + runTest("[:] [feed::]", "[feed::]") + runTest(":[::feee]:]", "[::feee]") + runTest(":[::feee]:]]", "[::feee]") + runTest("[[:[::feee]:]", "[::feee]") + } + + @Test + fun testIpv6ConsecutiveGoodUrls() { + runTest("[::afaf][eaea::][::]", "[::afaf]", "[eaea::]", "[::]") + runTest("[::afaf]www.google.com", "[::afaf]", "www.google.com") + runTest("[lalala:we][::]", "[::]") + runTest("[::fe][::]", "[::fe]", "[::]") + runTest("[aaaa::][:0:][::afaf]", "[aaaa::]", "[::afaf]") + } + + @Test + fun testIpv6BacktrackingUsernamePassword() { + runTest("check out my url:google.com", "google.com") + runTest( + "check out my url:[::BAD:DEAD:BEEF:2e80:0:0]", + "[::BAD:DEAD:BEEF:2e80:0:0]", + ) + runTest( + "check out my url:[::BAD:DEAD:BEEF:2e80:0:0] ", + "[::BAD:DEAD:BEEF:2e80:0:0]", + ) + } + + @Test + fun testIpv6BacktrackingEmptyDomainName() { + runTest("check out my http:///[::2e80:0:0]", "[::2e80:0:0]") + runTest("check out my http://./[::2e80:0:0]", "[::2e80:0:0]") + } + + @Test + fun testIpv6DoubleSchemeWithDomain() { + runTest("http://http://[::2e80:0:0]", "http://[::2e80:0:0]") + runTest( + "make sure its right here http://http://[::2e80:0:0]", + "http://[::2e80:0:0]", + ) + } + + @Test + fun testIpv6MultipleSchemes() { + runTest( + "http://http://http://[::2e80:0:0]", + "http://[::2e80:0:0]", + ) + runTest( + "make sure its right here http://http://[::2e80:0:0]", + "http://[::2e80:0:0]", + ) + runTest( + "http://ftp://https://[::2e80:0:0]", + "https://[::2e80:0:0]", + ) + runTest( + "make sure its right here http://ftp://https://[::2e80:0:0]", + "https://[::2e80:0:0]", + ) + } + + @Test + fun testIpv6FtpWithUsernameAndPassword() { + runTest( + "ftp with username is ftp://username:password@[::2e80:0:0]", + "ftp://username:password@[::2e80:0:0]", + ) + } + + @Test + fun testIpv6NewLinesAndTabsAreDelimiters() { + runTest( + "Do newlines and tabs break? [::2e80:0:0]/hello/\nworld [::BEEF:ADD:BEEF]\t/stuff/ [AAbb:AAbb:AAbb::]/\thello [::2e80:0:0\u0000]/hello world", + "[::2e80:0:0]/hello/", + "[::BEEF:ADD:BEEF]", + "stuff/", + "[AAbb:AAbb:AAbb::]/", + ) + } + + @Test + fun testIpv6WithPort() { + runTest( + "http://[AAbb:AAbb:AAbb::]:8080/helloworld", + "http://[AAbb:AAbb:AAbb::]:8080/helloworld", + ) + } + + @Test + fun testIpv6IncorrectParsingHtmlWithBadOptions() { + runTest( + "google.com", + "http://[::AAbb:]/\">google.com", + ) + } + + @Test + fun testIpv6EmptyPort() { + runTest( + "http://[::AAbb:]://foo.html", + "http://[::AAbb:]://foo.html", + ) + runTest( + "make sure its right here http://[::AAbb:]://foo.html", + "http://[::AAbb:]://foo.html", + ) + } + + @Test + fun testBacktrackInvalidUsernamePassword() { + runTest("http://hello:asdf.com", "asdf.com") + } + + /* + * https://github.com/linkedin/URL-Detector/issues/12 + */ + @Test + fun testIssue12() { + runTest( + "http://user:pass@host.com host.com", + "http://user:pass@host.com", + "host.com", + ) + } + + /* + * https://github.com/linkedin/URL-Detector/issues/15 + */ + @Test + fun testIssue15() { + runTest( + ".............:::::::::::;;;;;;;;;;;;;;;::...............................................:::::::::::::::::::::::::::::....................", + ) + } + + /* + * https://github.com/linkedin/URL-Detector/issues/16 + */ + @Test + fun testIssue16() { + runTest("://VIVE MARINE LE PEN//:@.") + } + + @Test + fun testColonWithoutSlashesFail() { + val parser = UrlDetector("ftp:example.com") + val found: List = parser.detect() + for (url in found) { + assertEquals(url.scheme, "ftp") + // Should be detected as a username now and set to default http:// + assertEquals(url.host, "example.com") + } + } + + @Test + fun testSingleLevelDomain() { + runTest("http://localhost:9000/lalala hehe", "http://localhost:9000/lalala") + runTest("localhost:9000/lalala hehe", "localhost:9000/lalala") + runTest("http://localhost lasdf", "http://localhost") + runTest("localhost:9000/lalala", "localhost:9000/lalala") + runTest("192.168.1.1/lalala", "192.168.1.1/lalala") + runTest("http://localhost", "http://localhost") + runTest("//localhost", "//localhost") + runTest("asf//localhost") + runTest("hello/", "hello/") + runTest("hello/ ", "hello/") + runTest("hello") + runTest("go/", "go/") + runTest("hello:password@go12//", "hello:password@go12//") + runTest("hello:password@go12", "hello:password@go12") + runTest("hello:password@go12 lala", "hello:password@go12") + runTest("hello.com..", "hello.com") + runTest("a/") + runTest("4/5") + runTest("concerns/worries") + runTest("asdflocalhost aksdjfhads") + runTest("/") + runTest("////") + runTest("hi:") + runTest("hi: ") + runTest("hi:\n") + runTest("testing normal phrase") + runTest("testing normal/something phrase") + runTest("testing normal: phrase") + } + + @Test + fun testLongSingleLabelDomain() { + runTest("user:password@localhost", "user:password@localhost") + } + + @Test + fun testShortSingleLabelDomain() { + runTest("user:password@go12", "user:password@go12") + } + + @Test + fun testIssueUnderscore() { + runTest("Neomobius_at_mstdn.jp@mostr.pub", "Neomobius_at_mstdn.jp@mostr.pub") + } + + @Test + fun testNostr() { + runTest("Check this post nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf . I think it is really cool", "nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf") + } + + @Test + fun testBlossom() { + runTest("Check this image blossom:somethingsomething . I think it is really cool", "blossom:somethingsomething") + } + + @Test + fun testNostrSlashes() { + runTest("Check this post nostr://npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf . I think it is really cool", "nostr://npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf") + } + + @Test + fun testBlossomWithSlashes() { + runTest("Check this image blossom://somethingsomething . I think it is really cool", "blossom://somethingsomething") + } + + @Test + fun testNostr2() { + runTest("I saw this on nostr: somethingsomething. I think it is really cool") + } + + @Test + fun testBlossom2() { + runTest("I saw this on blossom: somethingsomething. I think it is really cool") + } + + @Test + fun testUnsupportedSchema() { + runTest("I saw this on hxxp://test.com I think it is really cool") + } + + @Test + fun testBlossomSchema() { + runTest("blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth", "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth") + } + + @Test + fun testBasicIPv6() { + runTest("I saw this on http://[2001:db8:1f70:0:999:de8:7648:6e8] I think it is really cool", "http://[2001:db8:1f70:0:999:de8:7648:6e8]") + runTest("I saw this on http://[2001:db8::1]:80 I think it is really cool", "http://[2001:db8::1]:80") + runTest("I saw this on http://[2a01:5cc0:1:2::4] I think it is really cool", "http://[2a01:5cc0:1:2::4]") + runTest("I saw this on http://[::1]:3000 I think it is really cool", "http://[::1]:3000") + } + + @Test + fun testNoSchemaUrlMultibyteAscii() { + runTest("ほtest.com", "test.com") + runTest("test.comほ", "test.com") + runTest("ほtest.comほ", "test.com") + } + + @Test + fun testBeginsAndEndsWithPunctuation() { + UrlDetector.CANNOT_END_URLS_WITH.forEach { punctuation -> + runTest("${punctuation}http://test.com?s=dd", "http://test.com?s=dd") + runTest("http://test.com?s=dd$punctuation", "http://test.com?s=dd") + runTest("${punctuation}http://test.com?s=dd$punctuation", "http://test.com?s=dd") + + runTest("${punctuation}http://test.com/", "http://test.com/") + runTest("http://test.com/.", "http://test.com/") + runTest("${punctuation}http://test.com/$punctuation", "http://test.com/") + + runTest("${punctuation}http://test.com", "http://test.com") + runTest("http://test.com$punctuation", "http://test.com") + runTest("${punctuation}http://test.com$punctuation", "http://test.com") + + runTest("${punctuation}test.com", "test.com") + runTest("test.com$punctuation", "test.com") + runTest("${punctuation}test.com$punctuation", "test.com") + } + + UrlDetector.CANNOT_BEGIN_URLS_WITH.forEach { punctuation -> + runTest("${punctuation}http://test.com?s=dd", "http://test.com?s=dd") + runTest("http://test.com?s=dd$punctuation", "http://test.com?s=dd") + runTest("${punctuation}http://test.com?s=dd$punctuation", "http://test.com?s=dd") + + runTest("${punctuation}http://test.com/", "http://test.com/") + runTest("http://test.com/.", "http://test.com/") + runTest("${punctuation}http://test.com/$punctuation", "http://test.com/") + + runTest("${punctuation}http://test.com", "http://test.com") + runTest("http://test.com$punctuation", "http://test.com") + runTest("${punctuation}http://test.com$punctuation", "http://test.com") + + runTest("${punctuation}test.com", "test.com") + runTest("test.com$punctuation", "test.com") + runTest("${punctuation}test.com$punctuation", "test.com") + } + } + + private fun runTest( + text: String, + vararg expected: String?, + ) = assertEquals( + expected.toList(), + UrlDetector(text).detect().map { it.originalUrl }, + ) +} diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt index b34458b6b..ba6368617 100644 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip04Dm import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Encryption import kotlin.test.Test @@ -34,8 +34,8 @@ class EncryptionTest { val sk1 = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".hexToByteArray() val sk2 = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".hexToByteArray() - val pk1 = Nip01.pubKeyCreate(sk1) - val pk2 = Nip01.pubKeyCreate(sk2) + val pk1 = Nip01Crypto.pubKeyCreate(sk1) + val pk2 = Nip01Crypto.pubKeyCreate(sk2) val expectedShared = "7ce22696eb0e303ddaa491bdf2a56b79d249f2d861b8e012a933e01dc4beba81" diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 9043a1d8f..c56dd72b1 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -44,6 +44,7 @@ import java.net.Socket import java.security.MessageDigest import java.security.SecureRandom import java.security.cert.X509Certificate +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import javax.net.SocketFactory import javax.net.ssl.SSLContext @@ -83,7 +84,7 @@ class ElectrumXClient( isLenient = true } private val requestId = AtomicInteger(0) - private val mutex = Mutex() + private val serverMutexes = ConcurrentHashMap() companion object { private const val PROTOCOL_VERSION = "1.4" @@ -121,9 +122,13 @@ class ElectrumXClient( server: ElectrumxServer = DEFAULT_ELECTRUMX_SERVERS.first(), ): NameShowResult? = withContext(Dispatchers.IO) { + val mutex = serverMutexes.getOrPut("${server.host}:${server.port}") { Mutex() } mutex.withLock { try { connectAndQuery(identifier, server) + } catch (e: NamecoinLookupException) { + // Propagate name-not-found and expired — these are definitive answers. + throw e } catch (e: Exception) { // Log but don't crash — callers handle null gracefully. e.printStackTrace() @@ -137,16 +142,28 @@ class ElectrumXClient( * * @param identifier Full Namecoin name, e.g. "d/example" * @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS] + * @throws NamecoinLookupException.NameNotFound if the name definitively doesn't exist + * @throws NamecoinLookupException.NameExpired if the name has expired + * @throws NamecoinLookupException.ServersUnreachable if all servers failed with connection errors */ override suspend fun nameShowWithFallback( identifier: String, servers: List, ): NameShowResult? { + var lastError: Exception? = null for (server in servers) { - val result = nameShow(identifier, server) - if (result != null) return result + try { + val result = nameShow(identifier, server) + if (result != null) return result + } catch (e: NamecoinLookupException.NameNotFound) { + throw e // Definitive answer from blockchain — no point trying other servers + } catch (e: NamecoinLookupException.NameExpired) { + throw e // Definitive answer + } catch (e: Exception) { + lastError = e // Server error — try next server + } } - return null + throw NamecoinLookupException.ServersUnreachable(lastError) } // ── internals ────────────────────────────────────────────────────── @@ -175,7 +192,7 @@ class ElectrumXClient( writer.println(historyReq) val historyResponse = reader.readLine() ?: return null val historyEntries = parseHistoryResponse(historyResponse) ?: return null - if (historyEntries.isEmpty()) return null + if (historyEntries.isEmpty()) throw NamecoinLookupException.NameNotFound(identifier) // 4. Get the latest transaction (last entry = most recent update) val latestEntry = historyEntries.last() @@ -196,7 +213,7 @@ class ElectrumXClient( if (currentHeight != null && height > 0) { val blocksSinceUpdate = currentHeight - height if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) { - return null // Name has expired + throw NamecoinLookupException.NameExpired(identifier) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt index b1c7e12a8..bdb01fd24 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.utils -import com.linkedin.urls.detection.UrlDetector -import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -actual fun fastFindURLs(text: String): List = UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl } +actual fun fastFindURLs(text: String): List = UrlDetector(text).detect().map { it.originalUrl } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt index 5c884998c..9d875956b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt @@ -31,7 +31,7 @@ class FixedKey( ) : SecretKey { override fun getAlgorithm() = algo - override fun getEncoded() = key + override fun getEncoded() = key.copyOf() override fun getFormat() = "RAW" diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index ed69e5131..aaf315f00 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.utils.sha256 import java.io.InputStream -val pool = Sha256Pool(10) // max parallel operations +val pool = Sha256Pool(25) // max parallel operations actual fun sha256(data: ByteArray) = pool.hash(data) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt index 3b6e8fe98..2f5908010 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip64Chess +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt index 42488cbcb..52822c49c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -115,6 +115,36 @@ class NamecoinNameResolverTest { assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey) } + @Test + fun `root lookup falls back to first entry when no underscore key`() { + val value = """{ + "nostr": { + "names": { + "m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d" + } + } + }""" + + val result = extractNostrFromValue(value, "d/testls", "_") + assertNotNull(result) + assertEquals("6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d", result!!.pubkey) + assertEquals("m", result.localPart) + } + + @Test + fun `non-root lookup does NOT fall back to first entry`() { + val value = """{ + "nostr": { + "names": { + "m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d" + } + } + }""" + + val result = extractNostrFromValue(value, "d/testls", "alice") + assertNull(result) + } + // ── Value format: id/ namespace ──────────────────────────────────── @Test @@ -209,9 +239,40 @@ class NamecoinNameResolverTest { // Extended form if (nostrField is kotlinx.serialization.json.JsonObject) { val names = nostrField["names"]?.jsonObject ?: return null - val pubkeyElem = names[localPart] ?: names["_"] ?: return null - val pubkey = (pubkeyElem as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return null - if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null + + // Resolve: exact match → "_" root → first entry (root lookups only) + val resolvedLocalPart: String + val pubkey: String + + val exactMatch = names[localPart] + val rootMatch = names["_"] + val firstEntry = if (localPart == "_") names.entries.firstOrNull() else null + + when { + exactMatch is kotlinx.serialization.json.JsonPrimitive && + exactMatch.content.matches(Regex("^[0-9a-fA-F]{64}$")) -> { + resolvedLocalPart = localPart + pubkey = exactMatch.content + } + + rootMatch is kotlinx.serialization.json.JsonPrimitive && + rootMatch.content.matches(Regex("^[0-9a-fA-F]{64}$")) -> { + resolvedLocalPart = "_" + pubkey = rootMatch.content + } + + firstEntry != null && firstEntry.value is kotlinx.serialization.json.JsonPrimitive && + (firstEntry.value as kotlinx.serialization.json.JsonPrimitive) + .content + .matches(Regex("^[0-9a-fA-F]{64}$")) -> { + resolvedLocalPart = firstEntry.key + pubkey = (firstEntry.value as kotlinx.serialization.json.JsonPrimitive).content + } + + else -> { + return null + } + } val relays = try { @@ -227,7 +288,7 @@ class NamecoinNameResolverTest { pubkey = pubkey.lowercase(), relays = relays, namecoinName = namecoinName, - localPart = localPart, + localPart = resolvedLocalPart, ) } return null