Merge pull request #1825 from vitorpamplona/claude/event-sync-screen-sYGtN

Add Event Sync feature to redistribute events across relays
This commit is contained in:
Vitor Pamplona
2026-03-19 09:54:26 -04:00
committed by GitHub
77 changed files with 2509 additions and 322 deletions
-69
View File
@@ -1,69 +0,0 @@
name: Build APK For Claude
on:
push:
branches:
- 'claude/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-benchmark:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Benchmark APK
run: ./gradlew assemblePlayBenchmark
- name: Upload Play Benchmark APK
id: upload
uses: actions/upload-artifact@v6
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Comment on PR with APK link
uses: actions/github-script@v7
with:
script: |
const artifactId = `${{ steps.upload.outputs.artifact-id }}`;
const downloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`;
const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${downloadUrl})`;
const branch = context.ref.replace('refs/heads/', '');
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:${branch}`,
state: 'open'
});
for (const pr of prs) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body
});
}
@@ -0,0 +1,73 @@
/*
* 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.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
}
@@ -171,8 +171,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
@@ -135,8 +135,8 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -31,13 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectRequestCache
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectRequestCache
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
fun filterNWCPaymentsFromRequests(
serviceKeys: Set<HexKey>,
@@ -26,7 +26,7 @@ import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
@@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
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
@@ -217,6 +218,7 @@ fun AppNavigation(
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
@@ -51,6 +51,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -481,6 +482,14 @@ fun ListContent(
route = Route.Chess,
)
NavigationRow(
title = R.string.event_sync_title,
icon = Icons.Outlined.Sync,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.EventSync,
)
NavigationRow(
title = R.string.route_import_follows,
icon = Icons.Outlined.GroupAdd,
@@ -134,6 +134,8 @@ sealed class Route {
@Serializable object EditRelays : Route()
@Serializable object EventSync : Route()
@Serializable object EditMediaServers : Route()
@Serializable object UpdateReactionType : Route()
@@ -32,10 +32,16 @@ import kotlin.math.round
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
private const val MONTH_DATE_FORMAT = "MMM dd"
private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy"
private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd"
var locale: Locale = Locale.getDefault()
var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
fun timeAgo(
time: Long?,
context: Context,
@@ -116,6 +122,46 @@ fun timeAgoNoDot(
}
}
fun timeAgoNoDotNoDay(
time: Long?,
context: Context,
): String {
if (time == null) return " "
if (time == 0L) return " ${stringRes(context, R.string.never)}"
val timeDifference = TimeUtils.now() - time
return if (timeDifference > TimeUtils.ONE_YEAR) {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
yearNoDayFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_MONTH) {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale)
monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale)
}
monthNoDayFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
// 2 days
(timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d)
} else if (timeDifference > TimeUtils.ONE_HOUR) {
(timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h)
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m)
} else {
stringRes(context, R.string.now)
}
}
fun timeAheadNoDot(
time: Long?,
context: Context,
@@ -35,6 +35,7 @@ import coil3.asDrawable
import coil3.imageLoader
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
@@ -79,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
@@ -93,8 +95,10 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -123,7 +127,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
@@ -144,8 +148,10 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -175,6 +181,66 @@ class AccountViewModel(
val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope)
val eventSync =
EventSync(
accountPubKey = account.signer.pubKey,
relayDb = {
val stats = Amethyst.instance.relayStats.snapshot()
val relays =
account.cache.relayHints.relayDB
.keys()
.filter { url ->
val relayStat = stats[url]
// has connected at least once OR never tried.
if (relayStat != null) {
relayStat.connectionCompleted > 0 || relayStat.connectionTentatives == 0
} else {
true
}
}
val sortMap = relays.associateWith { stats.get(it)?.receivedBytes }
relays.sortedByDescending { sortMap[it] }
},
outboxTargets = { account.nip65RelayList.outboxFlow.value },
inboxTargets = { account.nip65RelayList.inboxFlow.value },
dmTargets = { account.dmRelayList.flow.value },
clientBuilder = {
// creates a new client to make sure these events don't end up polluting the local cache.
// Create a new scope that inherits the ViewModel's lifecycle
// but uses a SupervisorJob so child failures are independent.
val customScope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob())
// Provides a relay pool
val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope)
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
customScope,
signWithAllLoggedInUsers = { authTemplate ->
if (account.signer.isWriteable()) {
try {
listOf(account.signer.sign(authTemplate))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
emptyList()
}
} else {
emptyList()
}
},
)
newClient
},
scope = viewModelScope,
)
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -46,8 +46,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
@Composable
fun DisplayLNAddress(
@@ -191,8 +191,8 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -0,0 +1,636 @@
/*
* 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.relays.eventsync
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_ACTIVITY_LOG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_CONCURRENT_RELAYS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.LiveSyncActivity.SourceRelayInfo
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits
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.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
/**
* Syncs the user's events across all known relays:
* 1. Downloads all events authored by the user and sends them to their outbox relays.
* 2. Downloads all events that p-tag the user (non-DM) and sends them to their inbox relays.
* 3. Downloads kind-4 and kind-1059 events that p-tag the user and sends them to DM relays.
*
* Up to [MAX_CONCURRENT_RELAYS] relays are queried in parallel. As soon as one relay is fully
* exhausted (all pages retrieved) the next relay from the list starts immediately, keeping the
* concurrency window full at all times.
*
* Each relay is paginated individually: after EOSE the oldest [Event.createdAt] seen on that
* relay becomes the next `until` cursor, repeating until the relay returns no new events.
*
* OK (true) responses from destination relays are tracked via [IRelayClientListener] and
* attributed back to the source relay that contributed each event.
*
* Live activity is emitted via [liveActivity] so the UI can show a per-relay log of events
* received and events accepted by destination relays.
*
* Scoped to the AccountViewModel so the sync survives navigation within the same session.
*/
@Stable
class EventSync(
private val accountPubKey: HexKey,
private val relayDb: () -> List<NormalizedRelayUrl>,
private val outboxTargets: () -> Set<NormalizedRelayUrl>,
private val inboxTargets: () -> Set<NormalizedRelayUrl>,
private val dmTargets: () -> Set<NormalizedRelayUrl>,
private val clientBuilder: () -> INostrClient,
private val scope: CoroutineScope,
) {
companion object {
/** Maximum number of relays queried at the same time. */
const val MAX_CONCURRENT_RELAYS = 50
/** How long (ms) to wait for a single relay to reply per page before giving up. */
const val RELAY_TIMEOUT_MS = 30_000L
/** Maximum number of completed-relay entries kept in the activity log. */
const val MAX_ACTIVITY_LOG = 5000
}
// -------------------------------------------------------------------------
// Public state
// -------------------------------------------------------------------------
sealed class SyncState {
object Idle : SyncState()
data class Running(
val relaysCompleted: MutableStateFlow<Int>,
val totalRelays: MutableStateFlow<Int>,
val eventsSent: MutableStateFlow<Int>,
val eventsReceived: MutableStateFlow<Int>,
val eventsAccepted: MutableStateFlow<Int>,
) : SyncState() {
constructor(relaysCompleted: Int, totalRelays: Int, eventsSent: Int, eventsReceived: Int, eventsAccepted: Int) :
this(
relaysCompleted = MutableStateFlow(relaysCompleted),
totalRelays = MutableStateFlow(totalRelays),
eventsSent = MutableStateFlow(eventsSent),
eventsReceived = MutableStateFlow(eventsReceived),
eventsAccepted = MutableStateFlow(eventsAccepted),
)
}
data class Done(
val totalEventsReceived: Int,
val totalEventsSent: Int,
val totalEventsAccepted: Int,
val durationMs: Long,
) : SyncState()
data class Error(
val message: String,
) : SyncState()
}
/**
* Per-relay activity snapshot emitted continuously while the sync runs.
*
* @param completedRelays Last [MAX_ACTIVITY_LOG] relays that finished, sorted by most events
* found (descending) so the most productive sources appear first.
* @param outboxTargets Relays receiving events authored by the user.
* @param inboxTargets Relays receiving events that mention the user.
* @param dmTargets Relays receiving DMs addressed to the user.
*/
@Stable
data class LiveSyncActivity(
val runningRelays: Map<NormalizedRelayUrl, SourceRelayInfo> = emptyMap(),
val completedRelays: Map<NormalizedRelayUrl, SourceRelayInfo> = emptyMap(),
val outboxTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
val inboxTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
val dmTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
) {
companion object {
val DefaultOrder = compareByDescending<SourceRelayInfo> { it.eventsFound.value }.thenByDescending { it.status.value == ConnectionStatus.Completed }
}
val sortedCompletedRelays = completedRelays.values.sortedWith(DefaultOrder)
constructor(
runningRelays: List<SourceRelayInfo>,
completedRelays: List<SourceRelayInfo>,
outboxTargets: List<DestinationRelayInfo>,
inboxTargets: List<DestinationRelayInfo>,
dmTargets: List<DestinationRelayInfo>,
) : this(
runningRelays.associateBy { it.relay },
completedRelays.associateBy { it.relay },
outboxTargets.associateBy { it.relay },
inboxTargets.associateBy { it.relay },
dmTargets.associateBy { it.relay },
)
sealed interface ConnectionStatus {
object Connecting : ConnectionStatus
object Querying : ConnectionStatus
class Error(
val msg: String,
) : ConnectionStatus
object Completed : ConnectionStatus
}
/**
* @param eventsFound Total events received from this relay across all pages.
* @param eventsAccepted Events from this relay that destination relays accepted as new
* (OK true). Reflects the count at relay-completion time; late
* OK responses may not be included.
*/
@Stable
data class SourceRelayInfo(
val relay: NormalizedRelayUrl,
val status: MutableStateFlow<ConnectionStatus>,
val eventsFound: MutableStateFlow<Int>,
val eventsAccepted: MutableStateFlow<Int>,
val pageUntil: MutableStateFlow<Long?> = MutableStateFlow(null),
) {
constructor(relay: NormalizedRelayUrl, status: ConnectionStatus, eventsFound: Int, eventsAccepted: Int, untilPage: Long? = null) :
this(
relay = relay,
status = MutableStateFlow(status),
eventsFound = MutableStateFlow(eventsFound),
eventsAccepted = MutableStateFlow(eventsAccepted),
pageUntil = MutableStateFlow(untilPage),
)
}
/**
* @param relay The destination relay URL.
* @param eventsSent Number of events sent to this relay.
* @param eventsAccepted Number of OK=true responses received from this relay.
*/
@Stable
data class DestinationRelayInfo(
val relay: NormalizedRelayUrl,
val eventsSent: MutableStateFlow<Int>,
val eventsAccepted: MutableStateFlow<Int>,
) {
constructor(relay: NormalizedRelayUrl, eventsSent: Int, eventsAccepted: Int) :
this(relay, MutableStateFlow(eventsSent), MutableStateFlow(eventsAccepted))
}
}
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
val syncState: StateFlow<SyncState> = _syncState
private val _liveActivity = MutableStateFlow(LiveSyncActivity())
val liveActivity: StateFlow<LiveSyncActivity> = _liveActivity
private fun emitLiveSnapshot(
runningRelays: Set<NormalizedRelayUrl> = emptySet(),
completedRelays: Set<NormalizedRelayUrl> = emptySet(),
liveOutboxTargets: Set<NormalizedRelayUrl> = emptySet(),
liveInboxTargets: Set<NormalizedRelayUrl> = emptySet(),
liveDmTargets: Set<NormalizedRelayUrl> = emptySet(),
) {
_liveActivity.value =
LiveSyncActivity(
runningRelays =
runningRelays.associateWith {
SourceRelayInfo(
relay = it,
status = MutableStateFlow(LiveSyncActivity.ConnectionStatus.Connecting),
eventsFound = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
completedRelays =
completedRelays.associateWith {
SourceRelayInfo(
relay = it,
status = MutableStateFlow(LiveSyncActivity.ConnectionStatus.Connecting),
eventsFound = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
outboxTargets =
liveOutboxTargets.associateWith { relay ->
LiveSyncActivity.DestinationRelayInfo(
relay = relay,
eventsSent = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
inboxTargets =
liveInboxTargets.associateWith { relay ->
LiveSyncActivity.DestinationRelayInfo(
relay = relay,
eventsSent = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
dmTargets =
liveDmTargets.associateWith { relay ->
LiveSyncActivity.DestinationRelayInfo(
relay = relay,
eventsSent = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
)
}
// -------------------------------------------------------------------------
// Control functions
// -------------------------------------------------------------------------
private var syncJob: Job? = null
fun start() {
if (_syncState.value is SyncState.Running) return
_liveActivity.value = LiveSyncActivity()
syncJob =
scope.launch(Dispatchers.IO) {
runSync()
}
}
fun cancel() {
syncJob?.cancel()
}
// -------------------------------------------------------------------------
// Sync logic
// -------------------------------------------------------------------------
suspend fun runSync() {
val startTime = System.currentTimeMillis()
_liveActivity.value = LiveSyncActivity()
val myPubKey = accountPubKey
val relaysToProcess = relayDb()
if (relaysToProcess.isEmpty()) {
_syncState.value =
SyncState.Error("No known relays found. Browse some content first to discover relays.")
return
}
val totalRelays = relaysToProcess.size
val outboxTargets = outboxTargets()
val inboxTargets = inboxTargets()
val dmTargets = dmTargets()
val defaultFilters =
buildList {
if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey)))
if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) {
add(Filter(tags = mapOf("p" to listOf(myPubKey))))
}
}
if (defaultFilters.isEmpty()) {
_syncState.value = SyncState.Error("No outbox, inbox, or DM relays configured.")
return
}
val usersRelays = outboxTargets + inboxTargets + dmTargets
val perRelayFilters =
relaysToProcess.associateWith {
if (it !in usersRelays) {
defaultFilters
} else {
buildList {
if (it !in outboxTargets) add(Filter(authors = listOf(myPubKey)))
if (it !in inboxTargets && it !in dmTargets) {
add(Filter(tags = mapOf("p" to listOf(myPubKey))))
}
}
}
}
emitLiveSnapshot(
emptySet(),
emptySet(),
outboxTargets,
inboxTargets,
dmTargets,
)
// Thread-safe dedup sets — prevent the same event from being forwarded twice when
// multiple source relays return the same event concurrently.
val outboxDedup = ConcurrentHashMap.newKeySet<String>()
val inboxDedup = ConcurrentHashMap.newKeySet<String>()
val dmDedup = ConcurrentHashMap.newKeySet<String>()
val sourceRelayOfEvent = ConcurrentHashMap<HexKey, NormalizedRelayUrl>()
val runningState =
SyncState.Running(
relaysCompleted = 0,
totalRelays = totalRelays,
eventsSent = 0,
eventsReceived = 0,
eventsAccepted = 0,
)
val okListener =
object : IRelayClientListener {
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
super.onCannotConnect(relay, errorMessage)
val currentStatus = liveActivity.value.runningRelays[relay.url]?.status
if (currentStatus?.value !is LiveSyncActivity.ConnectionStatus.Error) {
currentStatus?.tryEmit(LiveSyncActivity.ConnectionStatus.Error(errorMessage))
}
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
super.onSent(relay, cmdStr, cmd, success)
if (cmd is EventCmd) {
var hasSent = false
if (outboxDedup.contains(cmd.event.id)) {
liveActivity.value.outboxTargets[relay.url]
?.eventsSent
?.update { it + 1 }
hasSent = true
}
if (inboxDedup.contains(cmd.event.id)) {
liveActivity.value.inboxTargets[relay.url]
?.eventsSent
?.update { it + 1 }
hasSent = true
}
if (dmDedup.contains(cmd.event.id)) {
liveActivity.value.dmTargets[relay.url]
?.eventsSent
?.update { it + 1 }
hasSent = true
}
if (hasSent) {
runningState.eventsSent.update { it + 1 }
}
} else if (cmd is ReqCmd) {
val currentStatus = liveActivity.value.runningRelays[relay.url]?.status
if (currentStatus?.value != LiveSyncActivity.ConnectionStatus.Querying) {
currentStatus?.tryEmit(LiveSyncActivity.ConnectionStatus.Querying)
}
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
if (msg is OkMessage && msg.success && msg.message.isBlank()) {
// remove() is atomic: returns non-null only for the first OK per event.
val sourceRelay = sourceRelayOfEvent.remove(msg.eventId)
if (sourceRelay != null) {
liveActivity.value.runningRelays[sourceRelay]
?.eventsAccepted
?.update { it + 1 }
}
if (outboxDedup.contains(msg.eventId)) {
val relayTarget = liveActivity.value.outboxTargets[relay.url]
if (relayTarget != null) {
relayTarget.eventsAccepted.update { it + 1 }
runningState.eventsAccepted.update { it + 1 }
}
}
if (dmDedup.contains(msg.eventId)) {
val relayTarget = liveActivity.value.dmTargets[relay.url]
if (relayTarget != null) {
relayTarget.eventsAccepted.update { it + 1 }
runningState.eventsAccepted.update { it + 1 }
}
}
if (inboxDedup.contains(msg.eventId)) {
val relayTarget = liveActivity.value.inboxTargets[relay.url]
if (relayTarget != null) {
relayTarget.eventsAccepted.update { it + 1 }
runningState.eventsAccepted.update { it + 1 }
}
}
}
}
}
_syncState.emit(runningState)
clientBuilder().use { client ->
client.subscribe(okListener)
try {
client.downloadFromPool(
relays = relaysToProcess,
filters = perRelayFilters,
onNewPage = { until, sourceRelay ->
_liveActivity.value.runningRelays[sourceRelay]
?.pageUntil
?.tryEmit(until)
},
onEvent = { event, sourceRelay ->
val isMyEvent = event.pubKey == myPubKey
val mentionsMe = event.tags.isTaggedUser(myPubKey)
val isDmKind = event.kind == 4 || event.kind == 1059
val live = liveActivity.value
var newEvent = false
var matchesAtLeastOneFilter = false
// Each routing rule is independent: an event can match more than one.
if (isMyEvent && outboxTargets.isNotEmpty()) {
if (outboxDedup.add(event.id)) {
client.send(event, outboxTargets)
newEvent = true
}
matchesAtLeastOneFilter = true
}
if (mentionsMe && isDmKind && dmTargets.isNotEmpty()) {
if (dmDedup.add(event.id)) {
client.send(event, dmTargets)
newEvent = true
}
matchesAtLeastOneFilter = true
}
if (mentionsMe && !isDmKind && inboxTargets.isNotEmpty()) {
if (inboxDedup.add(event.id)) {
client.send(event, inboxTargets)
newEvent = true
}
matchesAtLeastOneFilter = true
}
if (newEvent) {
sourceRelayOfEvent[event.id] = sourceRelay
}
if (matchesAtLeastOneFilter) {
runningState.eventsReceived.update { it + 1 }
live.runningRelays[sourceRelay]?.eventsFound?.update { it + 1 }
live.completedRelays[sourceRelay]?.eventsFound?.update { it + 1 }
}
},
onRelayStart = { relay ->
_liveActivity.update {
it.copy(
runningRelays =
it.runningRelays +
Pair(relay, SourceRelayInfo(relay, LiveSyncActivity.ConnectionStatus.Connecting, 0, 0)),
)
}
},
onRelayComplete = { relay ->
_liveActivity.update {
val newCompleted = it.runningRelays[relay]
it.copy(
runningRelays = it.runningRelays.minus(relay),
completedRelays =
if (newCompleted != null) {
it.completedRelays.plus(relay to newCompleted)
} else {
it.completedRelays
},
)
}
val status = _liveActivity.value.completedRelays[relay]?.status
if (status?.value !is LiveSyncActivity.ConnectionStatus.Error) {
status?.tryEmit(LiveSyncActivity.ConnectionStatus.Completed)
}
runningState.relaysCompleted.update { it + 1 }
},
)
_syncState.value =
SyncState.Done(
totalEventsReceived = runningState.eventsReceived.value,
totalEventsSent = runningState.eventsSent.value,
totalEventsAccepted = runningState.eventsAccepted.value,
durationMs = System.currentTimeMillis() - startTime,
)
} catch (e: Exception) {
_syncState.value =
SyncState.Done(
totalEventsReceived = runningState.eventsReceived.value,
totalEventsSent = runningState.eventsSent.value,
totalEventsAccepted = runningState.eventsAccepted.value,
durationMs = System.currentTimeMillis() - startTime,
)
if (e is CancellationException) throw e
_syncState.value = SyncState.Error(e.message ?: "Unknown error")
} finally {
client.unsubscribe(okListener)
}
}
}
/**
* Maintains a sliding window of up to [MAX_CONCURRENT_RELAYS] active relay workers.
* As soon as one relay finishes (all pages exhausted), the next relay from [relays]
* starts immediately no waiting for an entire batch to drain.
*
* [onEvent] receives the event and the URL of the relay it came from.
*/
private suspend fun INostrClient.downloadFromPool(
relays: List<NormalizedRelayUrl>,
filters: Map<NormalizedRelayUrl, List<Filter>>,
onNewPage: (Long, NormalizedRelayUrl) -> Unit,
onEvent: (Event, NormalizedRelayUrl) -> Unit,
onRelayStart: (NormalizedRelayUrl) -> Unit,
onRelayComplete: (NormalizedRelayUrl) -> Unit,
) {
val semaphore = Semaphore(MAX_CONCURRENT_RELAYS)
supervisorScope {
for (relay in relays) {
if (!isActive) break
semaphore.acquire()
launch {
try {
onRelayStart(relay)
filters[relay]?.let { filtersForRelay ->
downloadFromRelay(
relay = relay,
filters = filtersForRelay,
onNewPage = { onNewPage(it, relay) },
onEvent = { onEvent(it, relay) },
)
} ?: 0
onRelayComplete(relay)
} finally {
semaphore.release()
}
}
}
}
}
/**
* Fetches all pages from a single [relay] using paginated `until` cursors.
* Delegates to the Quartz [downloadFromRelay] extension.
*
* @return total number of events received across all pages.
*/
private suspend fun INostrClient.downloadFromRelay(
relay: NormalizedRelayUrl,
filters: List<Filter>,
onNewPage: (Long) -> Unit,
onEvent: (Event) -> Unit,
): Int = reqBypassingRelayLimits(relay, filters, RELAY_TIMEOUT_MS, onNewPage, onEvent)
}
@@ -0,0 +1,832 @@
/*
* 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.relays.eventsync
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDotNoDay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable
fun EventSyncScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val syncViewModel = accountViewModel.eventSync
val isMobileOrMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle()
val syncState by syncViewModel.syncState.collectAsStateWithLifecycle()
val liveActivity by syncViewModel.liveActivity.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopBarWithBackButton(
caption = stringRes(R.string.event_sync_title),
popBack = nav::popBack,
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
EventScreenBody(
syncState = syncState,
liveActivity = liveActivity,
isMobileOrMetered = isMobileOrMetered,
onStart = syncViewModel::start,
onCancel = syncViewModel::cancel,
)
}
}
}
@Composable
fun EventScreenBody(
syncState: EventSync.SyncState,
liveActivity: EventSync.LiveSyncActivity,
isMobileOrMetered: Boolean = false,
onStart: () -> Unit = {},
onCancel: () -> Unit = {},
) {
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
// ---- Progress / Status area ----
when (syncState) {
is EventSync.SyncState.Idle -> ExplanationCard(isMobileOrMetered, onStart)
is EventSync.SyncState.Running -> SyncProgressCard(state = syncState, onCancel)
is EventSync.SyncState.Done -> DoneCard(state = syncState, isMobileOrMetered, onStart)
is EventSync.SyncState.Error -> ErrorCard(syncState.message, isMobileOrMetered, onStart)
}
}
// ---- Live relay activity (shown during and after sync) ----
if (liveActivity.outboxTargets.isNotEmpty() ||
liveActivity.inboxTargets.isNotEmpty() ||
liveActivity.dmTargets.isNotEmpty()
) {
item {
Spacer(Modifier.height(16.dp))
DestinationRelaysCard(activity = liveActivity)
}
}
val runningSize = liveActivity.runningRelays.size
if (runningSize > 0) {
item {
Spacer(Modifier.height(16.dp))
Text(
text = stringRes(R.string.event_sync_activity_log, runningSize),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(5.dp))
}
itemsIndexed(liveActivity.runningRelays.values.toList(), key = { _, item -> item.relay.url }) { index, info ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ActivityLogRow(info = info)
}
}
val completedSize = liveActivity.completedRelays.size
if (completedSize > 0) {
item {
Spacer(Modifier.height(16.dp))
Text(
text = stringRes(R.string.event_sync_activity_log_finished, completedSize),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(5.dp))
}
itemsIndexed(liveActivity.sortedCompletedRelays, key = { _, item -> item.relay.url }) { index, info ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ActivityLogRow(info = info)
}
}
}
}
@Composable
private fun StartSyncButton(
isMobileOrMetered: Boolean,
onClick: () -> Unit,
) {
var showMobileDataDialog by remember { mutableStateOf(false) }
Button(
onClick = {
if (isMobileOrMetered) {
showMobileDataDialog = true
} else {
onClick()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.event_sync_start))
}
// ---- Mobile-data confirmation dialog ----
if (showMobileDataDialog) {
AlertDialog(
onDismissRequest = { showMobileDataDialog = false },
title = { Text(stringRes(R.string.event_sync_mobile_data_dialog_title)) },
text = { Text(stringRes(R.string.event_sync_wifi_warning)) },
confirmButton = {
Button(
onClick = {
showMobileDataDialog = false
onClick()
},
) {
Text(stringRes(R.string.event_sync_start_anyway))
}
},
dismissButton = {
TextButton(onClick = { showMobileDataDialog = false }) {
Text(stringRes(R.string.event_sync_cancel))
}
},
)
}
}
@Composable
private fun ExplanationCard(
isMobileOrMetered: Boolean,
onStart: () -> Unit,
) {
// ---- Explanation card ----
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_what_happens_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringRes(R.string.event_sync_what_happens_body),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
StepRow(number = "1", text = stringRes(R.string.event_sync_step1))
Spacer(Modifier.height(4.dp))
StepRow(number = "2", text = stringRes(R.string.event_sync_step2))
Spacer(Modifier.height(4.dp))
StepRow(number = "3", text = stringRes(R.string.event_sync_step3))
Spacer(Modifier.height(10.dp))
// ---- WiFi warning ----
if (isMobileOrMetered) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Text(
text = stringRes(R.string.event_sync_wifi_warning),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp),
)
}
Spacer(Modifier.height(10.dp))
}
StartSyncButton(isMobileOrMetered = isMobileOrMetered, onStart)
}
}
}
// -------------------------------------------------------------------------
// Progress / status cards
// -------------------------------------------------------------------------
@Composable
private fun SyncProgressCard(
state: EventSync.SyncState.Running,
onCancel: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
RelayStatement(state)
Spacer(Modifier.height(8.dp))
EventsReceivedStatement(state)
Spacer(Modifier.height(10.dp))
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.event_sync_cancel))
}
}
}
}
@Composable
private fun EventsReceivedStatement(state: EventSync.SyncState.Running) {
val eventsReceived by state.eventsReceived.collectAsStateWithLifecycle()
val eventsSent by state.eventsSent.collectAsStateWithLifecycle()
val eventsAccepted by state.eventsAccepted.collectAsStateWithLifecycle()
Text(
text = stringRes(R.string.event_sync_events_sent, eventsAccepted, eventsSent, eventsReceived),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun RelayStatement(state: EventSync.SyncState.Running) {
val relaysCompleted by state.relaysCompleted.collectAsStateWithLifecycle()
val totalRelays by state.totalRelays.collectAsStateWithLifecycle()
Text(
text = stringRes(R.string.event_sync_relays_progress, relaysCompleted, totalRelays),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = {
if (totalRelays > 0) {
relaysCompleted / totalRelays.toFloat()
} else {
0f
}
},
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun DoneCard(
state: EventSync.SyncState.Done,
isMobileOrMetered: Boolean = false,
onStart: () -> Unit = { },
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_done_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(Modifier.height(6.dp))
Text(
text = stringRes(R.string.event_sync_done_sent, state.totalEventsSent, state.totalEventsReceived),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(Modifier.height(2.dp))
Text(
text = stringRes(R.string.event_sync_done_accepted, state.totalEventsAccepted),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringRes(R.string.event_sync_done_duration, (state.durationMs / 1000).toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
)
Spacer(Modifier.height(10.dp))
StartSyncButton(isMobileOrMetered, onStart)
}
}
}
@Composable
private fun ErrorCard(
message: String,
isMobileOrMetered: Boolean = false,
onStart: () -> Unit = {},
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_error_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(4.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(10.dp))
StartSyncButton(isMobileOrMetered, onStart)
}
}
}
// -------------------------------------------------------------------------
// Live activity cards
// -------------------------------------------------------------------------
/**
* Shows where events are being sent: outbox, inbox, and DM relay lists.
*/
@Composable
private fun DestinationRelaysCard(activity: EventSync.LiveSyncActivity) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_sending_to),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (activity.outboxTargets.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
DestinationSection(
label = stringRes(R.string.event_sync_outbox_relays),
relays = activity.outboxTargets.values,
color = MaterialTheme.colorScheme.primary,
)
}
if (activity.inboxTargets.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(10.dp))
DestinationSection(
label = stringRes(R.string.event_sync_inbox_relays),
relays = activity.inboxTargets.values,
color = MaterialTheme.colorScheme.secondary,
)
}
if (activity.dmTargets.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(10.dp))
DestinationSection(
label = stringRes(R.string.event_sync_dm_relays),
relays = activity.dmTargets.values,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
}
@Composable
private fun DestinationSection(
label: String,
relays: Collection<EventSync.LiveSyncActivity.DestinationRelayInfo>,
color: androidx.compose.ui.graphics.Color,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
relays.forEachIndexed { index, info ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
DestinationRelayRow(info = info, color = color)
}
}
}
@Composable
private fun DestinationRelayRow(
info: EventSync.LiveSyncActivity.DestinationRelayInfo,
color: androidx.compose.ui.graphics.Color,
) {
val eventsSent by info.eventsSent.collectAsStateWithLifecycle()
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(color),
)
Text(
text = info.relay.displayHost(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(1f),
)
if (eventsSent > 0) {
val eventsAccepted by info.eventsAccepted.collectAsStateWithLifecycle()
Text(
text = stringRes(R.string.event_sync_log_sent, formatCount(eventsSent)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.3f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
)
Text(
text = stringRes(R.string.event_sync_log_new, formatCount(eventsAccepted)),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (eventsAccepted > 0) color else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.3f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
)
}
}
}
@Composable
private fun ActivityLogRow(info: EventSync.LiveSyncActivity.SourceRelayInfo) {
val eventsFound by info.eventsFound.collectAsStateWithLifecycle()
val hasEvents = eventsFound > 0
val dotColor =
if (hasEvents) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
}
val textColor =
if (hasEvents) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor),
)
Text(
text = info.relay.displayHost(),
style = MaterialTheme.typography.bodySmall,
color = textColor,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(0.6f),
)
if (hasEvents) {
val context = LocalContext.current
val untilPage by info.pageUntil.collectAsStateWithLifecycle()
val eventsAccepted by info.eventsAccepted.collectAsStateWithLifecycle()
untilPage?.let {
Text(
text = stringRes(R.string.event_sync_less_than_until, timeAgoNoDotNoDay(it, context)),
style = MaterialTheme.typography.bodySmall,
color = textColor,
modifier = Modifier.weight(0.3f),
maxLines = 1,
textAlign = TextAlign.End,
overflow = TextOverflow.StartEllipsis,
)
}
Text(
text = stringRes(R.string.event_sync_log_recv, formatCount(eventsFound)),
style = MaterialTheme.typography.bodySmall,
color = textColor,
modifier = Modifier.weight(0.3f),
maxLines = 1,
textAlign = TextAlign.End,
overflow = TextOverflow.StartEllipsis,
)
Text(
text = stringRes(R.string.event_sync_log_new, formatCount(eventsAccepted)),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal,
color =
if (eventsAccepted > 0) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
textAlign = TextAlign.End,
overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(0.3f),
)
} else {
val status by info.status.collectAsStateWithLifecycle()
Text(
text =
when (status) {
EventSync.LiveSyncActivity.ConnectionStatus.Connecting -> stringRes(R.string.event_sync_status_connecting)
EventSync.LiveSyncActivity.ConnectionStatus.Querying -> stringRes(R.string.event_sync_status_downloading)
is EventSync.LiveSyncActivity.ConnectionStatus.Error -> (status as EventSync.LiveSyncActivity.ConnectionStatus.Error).msg.ifBlank { stringRes(R.string.event_sync_status_error) }
EventSync.LiveSyncActivity.ConnectionStatus.Completed -> stringRes(R.string.event_sync_status_completed)
},
style = MaterialTheme.typography.bodySmall,
color = textColor,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(0.45f),
)
}
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@Composable
private fun StepRow(
number: String,
text: String,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = "$number.",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}
/** Strips the WebSocket scheme and trailing slash for compact display. */
private fun NormalizedRelayUrl.displayHost(): String =
url
.removePrefix("wss://")
.removePrefix("ws://")
.trimEnd('/')
/** Formats a count with K/M suffix for large numbers. */
private fun formatCount(n: Int): String =
when {
n >= 1_000_000 -> "${n / 1_000}K"
else -> n.toString()
}
// -------------------------------------------------------------------------
// Preview data
// -------------------------------------------------------------------------
private val previewRunning =
listOf(
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://relay.damus.io"), EventSync.LiveSyncActivity.ConnectionStatus.Querying, 1247, 891),
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nos.lol"), EventSync.LiveSyncActivity.ConnectionStatus.Querying, 892, 45),
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nos2.lol"), EventSync.LiveSyncActivity.ConnectionStatus.Connecting, 0, 0),
)
private val previewCompletions =
listOf(
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://relay.nostr.band"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 3500, 3498),
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://slow.relay.example.com"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 0, 0),
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nostr.bitcoiner.social"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 15, 0),
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://unreachable.relay.xyz"), EventSync.LiveSyncActivity.ConnectionStatus.Error("connection failed"), 0, 0),
)
private val previewActivity =
EventSync.LiveSyncActivity(
runningRelays = previewRunning,
completedRelays = previewCompletions,
outboxTargets =
listOf(
EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://outbox.nostr.com"), 1247, 891),
EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://relay.damus.io"), 892, 45),
),
inboxTargets =
listOf(
EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://inbox.nostr.com"), 500, 500),
EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://nos.lol"), 0, 0),
),
dmTargets =
listOf(
EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://dm.nostr.com"), 15, 10),
),
)
// -------------------------------------------------------------------------
// Previews
// -------------------------------------------------------------------------
@Composable
@Preview
fun IdleCardWifiPreview() {
ThemeComparisonColumn {
ExplanationCard(false, {})
}
}
@Composable
@Preview
fun IdleCardMobilePreview() {
ThemeComparisonColumn {
ExplanationCard(true, {})
}
}
@Composable
@Preview
fun SyncProgressCardPreview() {
ThemeComparisonColumn {
SyncProgressCard(
state =
EventSync.SyncState.Running(
relaysCompleted = 312,
totalRelays = 1024,
eventsAccepted = 12,
eventsSent = 4821,
eventsReceived = 10000,
),
onCancel = {},
)
}
}
@Composable
@Preview
fun DoneCardPreview() {
ThemeComparisonColumn {
DoneCard(
state =
EventSync.SyncState.Done(
totalEventsReceived = 20_000,
totalEventsSent = 18_432,
totalEventsAccepted = 14_891,
durationMs = 187_000,
),
)
}
}
@Composable
@Preview
fun ErrorCardPreview() {
ThemeComparisonColumn {
ErrorCard(message = "No outbox, inbox, or DM relays configured.")
}
}
@Composable
@Preview
fun DestinationRelaysCardPreview() {
ThemeComparisonColumn {
DestinationRelaysCard(activity = previewActivity)
}
}
@Composable
@Preview(device = "spec:width=1800px,height=2340px,dpi=440")
fun EventScreenBodyPreview() {
ThemeComparisonRow {
EventScreenBody(
EventSync.SyncState.Idle,
EventSync.LiveSyncActivity(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()),
)
}
}
@Composable
@Preview(device = "spec:width=1800px,height=2340px,dpi=440")
fun EventScreenBody2Preview() {
ThemeComparisonRow {
EventScreenBody(
EventSync.SyncState.Running(1047, 1224, 100, 4821, 10000),
previewActivity,
)
}
}
@@ -67,8 +67,8 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransactionType
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Date
@@ -23,19 +23,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
+42
View File
@@ -1811,10 +1811,52 @@
<string name="select_all">Select All</string>
<string name="uptime">%1$d%% uptime</string>
<string name="namecoin_settings">Namecoin Settings</string>
<string name="event_sync_title">Relay Sync</string>
<string name="event_sync_section">Relay Sync</string>
<string name="event_sync_section_explainer">Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data.</string>
<string name="event_sync_open_button">Open Relay Sync…</string>
<string name="event_sync_what_happens_title">What this does</string>
<string name="event_sync_what_happens_body">This tool scans every relay your app has seen and redistributes your events to the correct destinations: </string>
<string name="event_sync_step1">Download all events you authored and send them to your outbox relays.</string>
<string name="event_sync_step2">Download all events that mention you and send them to your inbox relays.</string>
<string name="event_sync_step3">Download all direct messages addressed to you and send them to your DM relays.</string>
<string name="event_sync_wifi_warning">⚠ You appear to be on a metered or mobile connection. This operation can transfer a very large amount of data. Connect to Wi-Fi before starting.</string>
<string name="event_sync_mobile_data_dialog_title">Use Mobile Data?</string>
<string name="event_sync_start">Start Sync</string>
<string name="event_sync_start_anyway">Start Anyway (mobile data)</string>
<string name="event_sync_pause">Pause</string>
<string name="event_sync_resume">Resume</string>
<string name="event_sync_start_over">Start Over</string>
<string name="event_sync_cancel">Cancel</string>
<string name="event_sync_relays_progress">Relays: %1$d / %2$d</string>
<string name="event_sync_events_sent">Events redistributed: %1$d new out of %2$d sent and %3$d received</string>
<string name="event_sync_paused_title">Sync Paused</string>
<string name="event_sync_paused_body">Completed %1$d of %2$d relays — %3$d events redistributed so far. Tap Resume to continue.</string>
<string name="event_sync_done_title">Sync complete</string>
<string name="event_sync_done_sent">Forwarded %1$d events to destination relays out of %2$d received.</string>
<string name="event_sync_done_accepted">%1$d events accepted as new by destination relays.</string>
<string name="event_sync_done_duration">Completed in %1$d seconds.</string>
<string name="event_sync_error_title">Sync error</string>
<string name="event_sync_sending_to">Sending To</string>
<string name="event_sync_outbox_relays">Outbox</string>
<string name="event_sync_inbox_relays">Inbox</string>
<string name="event_sync_dm_relays">DMs</string>
<string name="event_sync_activity_log">Currently Checking (%1$d relays)</string>
<string name="event_sync_activity_log_finished">Finished (%1$d relays)</string>
<string name="event_sync_log_sent">sent %1$s</string>
<string name="event_sync_log_recv">recv %1$s</string>
<string name="event_sync_log_new">new %1$s</string>
<string name="event_sync_no_events">no events</string>
<string name="ots_explorer_settings">Bitcoin Explorer (OTS)</string>
<string name="events">events</string>
<string name="dms">DMs</string>
<string name="profiles">profiles</string>
<string name="relay_settings_lower">relay settings</string>
<string name="last_seen">Last seen %1$s ago</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Connecting</string>
<string name="event_sync_status_downloading">Downloading</string>
<string name="event_sync_status_error">Error</string>
<string name="event_sync_status_completed">Completed</string>
</resources>
@@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.DualCase
@@ -48,10 +48,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.services.nwc
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import java.util.concurrent.ConcurrentHashMap
/**
@@ -33,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
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.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -35,10 +35,10 @@ import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -27,12 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
@@ -25,6 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import kotlin.test.Test
@@ -33,12 +33,12 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerMessageKSerializer
import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerRequestKSerializer
import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerResponseKSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47NotificationKSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47RequestKSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47ResponseKSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization.RumorKSerializer
import kotlinx.serialization.json.Json
@@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
interface INostrClient {
interface INostrClient : AutoCloseable {
fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>>
fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>>
@@ -136,4 +136,6 @@ object EmptyNostrClient : INostrClient {
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun close() {}
}
@@ -79,7 +79,8 @@ class NostrClient(
private val websocketBuilder: WebsocketBuilder,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
) : INostrClient,
IRelayClientListener {
IRelayClientListener,
AutoCloseable {
private val relayPool: RelayPool = RelayPool(websocketBuilder, this)
private val activeRequests: PoolRequests = PoolRequests()
@@ -326,4 +327,8 @@ class NostrClient(
override fun connectedRelaysFlow() = relayPool.connectedRelays
override fun availableRelaysFlow() = relayPool.availableRelays
override fun close() {
disconnect()
}
}
@@ -0,0 +1,178 @@
/*
* 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.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient.close
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient.openReqSubscription
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext
/**
* Downloads all pages of events matching [filters] from a single [relay] using
* paginated `until` cursors.
*
* After EOSE the oldest [Event.createdAt] seen in that page minus one becomes the
* next `until`, and the query repeats until the relay returns no new events.
*
* Event counting is tracked per filter using [Filter.match]. A filter is considered
* fulfilled when the number of matching events reaches its [Filter.limit]. Pagination
* stops when all filters with limits are fulfilled or when a page returns no events.
* Filters without a limit are considered unbounded and only stop on empty pages.
*
* @param relay The relay to query.
* @param filters Filters to apply on every page (the `until` field is overwritten per page).
* @param timeoutMs Maximum time to wait for a single page's EOSE before giving up.
* @param onEvent Called for every event received (in page order, after each EOSE).
* @return Total number of events received across all pages.
*/
suspend fun INostrClient.reqBypassingRelayLimits(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
onNewPage: ((Long) -> Unit)? = null,
onEvent: (Event) -> Unit,
): Int {
var until: Long? = null
var totalEvents = 0
// Track how many matching events each filter has received so far.
val matchCountPerFilter = IntArray(filters.size)
val subId = newSubId()
while (true) {
coroutineContext.ensureActive()
// Only include filters that still need more events.
val remainingFilters =
filters.filterIndexed { index, filter ->
val limit = filter.limit
limit == null || matchCountPerFilter[index] < limit
}
if (remainingFilters.isEmpty()) break
val doneChannel = Channel<Unit>(Channel.CONFLATED)
val activeFilters =
if (until == null) {
remainingFilters
} else {
onNewPage?.invoke(until)
remainingFilters.map { it.copy(until = until) }
}
var pageCount = 0
var pageMinTs = Long.MAX_VALUE
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relayInner: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onEvent(event)
pageCount++
if (event.createdAt < pageMinTs) pageMinTs = event.createdAt
// Count this event against every base filter it matches.
if (matchCountPerFilter.size == 1) {
// no need to run the match.
matchCountPerFilter[0]++
} else {
for (i in filters.indices) {
val limit = filters[i].limit
if ((limit == null || matchCountPerFilter[i] < limit) && filters[i].match(event)) {
matchCountPerFilter[i]++
}
}
}
}
override fun onEose(
relayInner: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
override fun onClosed(
message: String,
relayInner: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
override fun onCannotConnect(
relayInner: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
}
openReqSubscription(subId, mapOf(relay to activeFilters), listener)
withTimeoutOrNull(timeoutMs) {
doneChannel.receive()
}
close(subId)
doneChannel.close()
if (pageCount == 0) break
totalEvents += pageCount
// Advance cursor: next page starts just before the oldest event seen.
until = pageMinTs - 1
}
return totalEvents
}
suspend fun INostrClient.reqBypassingRelayLimits(
relay: String,
filters: List<Filter>,
timeoutMs: Long = 30_000L,
onNewPage: ((Long) -> Unit)? = null,
onEvent: (Event) -> Unit,
): Int =
reqBypassingRelayLimits(
relay = RelayUrlNormalizer.normalize(relay),
filters = filters,
timeoutMs = timeoutMs,
onNewPage = onNewPage,
onEvent = onEvent,
)
@@ -67,7 +67,7 @@ suspend fun INostrClient.downloadFirstEvent(
subscriptionId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
): Event? {
val resultChannel = Channel<Event>(UNLIMITED)
val resultChannel = Channel<Event?>(UNLIMITED)
val listener =
object : IRequestListener {
@@ -79,6 +79,29 @@ suspend fun INostrClient.downloadFirstEvent(
) {
resultChannel.trySend(event)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
resultChannel.trySend(null)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
resultChannel.trySend(null)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
resultChannel.trySend(null)
}
}
openReqSubscription(subscriptionId, filters, listener)
@@ -28,26 +28,63 @@ 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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlin.compareTo
class PoolEventOutbox {
private var eventOutbox = mapOf<HexKey, PoolEventOutboxState>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
fun updateRelays() {
val myRelays = mutableSetOf<NormalizedRelayUrl>()
eventOutbox.values.forEach {
myRelays.addAll(it.relaysLeft())
fun needsToUpdateRelays(): Boolean {
val currentRelays = relays.value
var relaysToRemoveCounter = 0
currentRelays.forEach { currentRelay ->
if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) {
relaysToRemoveCounter++
}
}
if (relays.value != myRelays) {
relays.tryEmit(myRelays)
var relaysToAddCounter = 0
eventOutbox.values.forEach { outboxState ->
if (outboxState.relaysRemaining.any { it !in currentRelays }) {
relaysToAddCounter++
}
}
return relaysToRemoveCounter > 0 || relaysToAddCounter > 0
}
fun updateRelays() {
if (needsToUpdateRelays()) {
relays.update { currentRelays ->
val relaysToRemove = mutableSetOf<NormalizedRelayUrl>()
currentRelays.forEach { currentRelay ->
if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) {
relaysToRemove.add(currentRelay)
}
}
val relaysToAdd = mutableSetOf<NormalizedRelayUrl>()
eventOutbox.values.forEach { outboxState ->
outboxState.relaysRemaining.forEach { relay ->
if (relay !in relaysToAdd && relay !in currentRelays) {
relaysToAdd.add(relay)
}
}
}
(currentRelays - relaysToRemove) + relaysToAdd
}
}
}
fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> {
val myEvents = mutableSetOf<HexKey>()
eventOutbox.forEach { (eventId, outboxCache) ->
if (url in outboxCache.relays) {
if (url in outboxCache.relaysRemaining) {
myEvents.add(eventId)
}
}
@@ -72,7 +109,12 @@ class PoolEventOutbox {
id: HexKey,
url: NormalizedRelayUrl,
) {
eventOutbox[id]?.newTry(url)
val waiting = eventOutbox[id]
waiting?.newTry(url)
if (waiting?.isDone() == true) {
eventOutbox = eventOutbox - waiting.event.id
updateRelays()
}
}
fun newResponse(
@@ -84,15 +126,13 @@ class PoolEventOutbox {
val waiting = eventOutbox[id]
if (waiting != null) {
waiting.newResponse(url, success, message)
clear()
if (waiting.isDone()) {
eventOutbox = eventOutbox - waiting.event.id
updateRelays()
}
}
}
fun clear() {
eventOutbox = eventOutbox.filter { !it.value.isDone() }
updateRelays()
}
// --------------------------
// State management functions
// --------------------------
@@ -143,7 +183,7 @@ class PoolEventOutbox {
errorMessage: String,
) {
eventOutbox.forEach {
if (relay in it.value.relays) {
if (relay in it.value.relaysRemaining) {
newResponse(it.key, relay, false, errorMessage)
}
}
@@ -26,35 +26,37 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class PoolEventOutboxState(
val event: Event,
var relays: Set<NormalizedRelayUrl>,
var relaysRemaining: Set<NormalizedRelayUrl>,
) {
private var tries = mapOf<NormalizedRelayUrl, Tries>()
private var failures = mapOf<NormalizedRelayUrl, Tries>()
fun updateRelays(newRelays: Set<NormalizedRelayUrl>) {
relays = newRelays
relaysRemaining = newRelays
}
fun isDone(url: NormalizedRelayUrl) = tries[url]?.isDone() ?: false
fun isDone() = relaysRemaining.isEmpty()
fun isDone() = relays.all { isDone(it) }
fun relaysLeft(): Set<NormalizedRelayUrl> = relaysRemaining
fun relaysLeft(): Set<NormalizedRelayUrl> = relays.filterTo(mutableSetOf()) { !isDone(it) }
fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url)
fun isSupposedToGo(url: NormalizedRelayUrl) = url in relaysRemaining
fun forEachUnsentEvent(
url: NormalizedRelayUrl,
run: (url: Event) -> Unit,
) = if (isSupposedToGo(url)) run(event) else null
fun remainingRelays() = relays.filterTo(mutableSetOf(), ::isSupposedToGo)
fun remainingRelays() = relaysRemaining
fun newTry(url: NormalizedRelayUrl) {
val currentTries = tries[url]
val currentTries = failures[url]
if (currentTries != null) {
currentTries.addTriedTime(TimeUtils.now())
if (currentTries.isDone()) {
relaysRemaining = relaysRemaining - url
failures = failures - url
}
} else {
tries = tries + (url to Tries(listOf(TimeUtils.now())))
failures = failures + (url to Tries(listOf(TimeUtils.now())))
}
}
@@ -63,38 +65,44 @@ class PoolEventOutboxState(
success: Boolean,
message: String,
) {
val currentTries = tries[url]
if (currentTries != null) {
currentTries.addResponse(Response(success, message))
val currentTries = failures[url]
if (success || message.shouldDiscard()) {
relaysRemaining = relaysRemaining - url
failures = failures - url
} else {
tries = tries + (
url to
Tries(
listOf(TimeUtils.now() - 1),
listOf(Response(success, message)),
)
)
if (currentTries != null) {
currentTries.addResponse(message)
} else {
failures = failures + (
url to
Tries(
listOf(TimeUtils.now() - 1),
listOf(message),
)
)
}
}
}
fun String.shouldDiscard() =
this.startsWith("replaced:") ||
this.startsWith("pow:") ||
this.startsWith("deleted:") ||
this.startsWith("invalid:")
// Tries 3 times
class Tries(
var tries: List<Long> = listOf(),
var responses: List<Response> = listOf(),
var responses: List<String> = listOf(),
) {
fun isDone() = responses.any { it.success } || responses.size > 2 || tries.size > 3
fun isDone() = responses.size > 2 || tries.size > 3
fun addResponse(r: Response) {
responses += r
fun addResponse(msg: String) {
responses += msg
}
fun addTriedTime(tried: Long) {
tries += tried
}
}
class Response(
val success: Boolean,
val message: String,
)
}
@@ -265,11 +265,15 @@ class PoolRequests {
errorMessage: String,
) {
relayState.forEach { subId, state ->
desiredSubListeners.get(subId)?.onCannotConnect(
message = errorMessage,
relay = url,
forFilters = state.lastKnownFilterStates(url),
)
// These are all my subs.. need to figure out which relays have them
val subs = desiredSubs.get(subId)
if (subs != null && url in subs.keys) {
desiredSubListeners.get(subId)?.onCannotConnect(
relay = url,
message = errorMessage,
forFilters = state.lastKnownFilterStates(url),
)
}
}
}
@@ -42,6 +42,8 @@ class RelayStats(
override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat()
}
fun snapshot(): Map<NormalizedRelayUrl, RelayStat> = innerCache.snapshot()
fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen")
private val clientListener =
@@ -27,6 +27,26 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
/**
* High-level NIP-47 Wallet Connect client.
@@ -23,6 +23,29 @@ package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
/**
* High-level NIP-47 Wallet Connect server (wallet service).
@@ -18,11 +18,13 @@
* 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.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.cache
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
class NostrWalletConnectRequestCache(
signer: NostrSigner,
@@ -18,11 +18,13 @@
* 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.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.cache
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
class NostrWalletConnectResponseCache(
signer: NostrSigner,
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedData
import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedData
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
@@ -20,32 +20,32 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionParams
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsParams
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendParams
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageParams
import com.vitorpamplona.quartz.nip47WalletConnect.TlvRecord
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
@@ -20,26 +20,26 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
enum class NwcErrorCode {
RATE_LIMITED,
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
object NwcMethod {
const val PAY_INVOICE = "pay_invoice"
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
object NwcTransactionType {
const val INCOMING = "incoming"
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
@@ -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.quartz.nip47WalletConnect
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
@@ -76,10 +76,10 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -101,6 +101,8 @@ private class TrackingNostrClient : INostrClient {
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<String> = emptySet()
override fun close() {}
}
/**
@@ -21,6 +21,37 @@
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionState
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
@@ -24,6 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -21,6 +21,10 @@
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
@@ -20,6 +20,13 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcBudgetRenewal
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionState
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -21,6 +21,22 @@
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -21,6 +21,23 @@
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
@@ -24,6 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -52,15 +52,15 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
@@ -24,11 +24,11 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
import com.vitorpamplona.quartz.utils.asTextOrNull
class NotificationDeserializer : StdDeserializer<Notification>(Notification::class.java) {
@@ -23,10 +23,10 @@ package com.vitorpamplona.quartz.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
class NotificationSerializer : StdSerializer<Notification>(Notification::class.java) {
override fun serialize(
@@ -24,21 +24,21 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
import com.vitorpamplona.quartz.utils.asTextOrNull
class RequestDeserializer : StdDeserializer<Request>(Request::class.java) {
@@ -23,17 +23,17 @@ package com.vitorpamplona.quartz.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
class RequestSerializer : StdSerializer<Request>(Request::class.java) {
override fun serialize(
@@ -24,24 +24,24 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
import com.vitorpamplona.quartz.utils.asTextOrNull
class ResponseDeserializer : StdDeserializer<Response>(Response::class.java) {
@@ -23,22 +23,22 @@ package com.vitorpamplona.quartz.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
class ResponseSerializer : StdSerializer<Response>(Response::class.java) {
override fun serialize(
@@ -21,11 +21,34 @@
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class DefaultContentTypeInterceptor(
private val userAgentHeader: String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", userAgentHeader)
.build()
return chain.proceed(requestWithUserAgent)
}
}
open class BaseNostrClientTest {
companion object {
val rootClient = OkHttpClient.Builder().build()
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
}
@@ -0,0 +1,120 @@
/*
* 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.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
@Test
fun testDownloadFromRelayReturnsMetadataEvents() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val events = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound =
client.reqBypassingRelayLimits(
relay = "wss://nos.lol",
filters =
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 1000,
),
),
) { event ->
events.add(event)
}
client.disconnect()
delay(500)
appScope.cancel()
assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol")
assertEquals(1000, events.size, "Events list should be 1000 events")
events.forEach { event ->
assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}")
}
}
@Test
fun testDownloadFromRelayReturnsMetadataAndContactListEvents() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val metadataEvents = mutableListOf<Event>()
val contactListEvents = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound =
client.reqBypassingRelayLimits(
relay = "wss://nos.lol",
filters =
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 1000,
),
Filter(
kinds = listOf(ContactListEvent.KIND),
limit = 1500,
),
),
) { event ->
if (event.kind == MetadataEvent.KIND) {
metadataEvents.add(event)
}
if (event.kind == ContactListEvent.KIND) {
contactListEvents.add(event)
}
}
client.disconnect()
delay(500)
appScope.cancel()
assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol")
assertEquals(1000, metadataEvents.size, "Events list should be 1000 events")
assertEquals(1500, contactListEvents.size, "Events list should be 1000 events")
metadataEvents.forEach { event ->
assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}")
}
contactListEvents.forEach { event ->
assertEquals(ContactListEvent.KIND, event.kind, "All events should be kind ${ContactListEvent.KIND}")
}
}
}
@@ -24,6 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals