Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-08-16 01:34:30 +00:00
committed by GitHub
45 changed files with 888 additions and 594 deletions
+76 -2
View File
@@ -251,10 +251,84 @@ dependencyResolutionManagement {
}
```
Add the dependency
Add the dependency by using one of the versions [here](https://jitpack.io/#vitorpamplona/amethyst/)
```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1')
implementation('com.github.vitorpamplona.amethyst:quartz:<tag, commit, -SNAPSHOT>')
```
Manage logged in users with the `KeyPair` class
```kt
val keys = KeyPair() // creates a random key
val keys = KeyPair("hex...".hexToByteArray())
val keys = KeyPair("nsec1...".bechToBytes())
val keys = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>"))
val readOnly = KeyPair(pubKey = "hex...".hexToByteArray())
val readOnly = KeyPair(pubKey = "npub1...".bechToBytes())
```
Create signers that can be internal, when you have the private key or when it is a read-only user
or external, when it is controlled by Amber in NIP-55
the `NostrSignerInternal` and `NostrSignerExternal` classes.
```kt
val signer = NostrSignerInternal(keyPair)
val amberSigner = NostrSignerExternal(
pubKey = keyPair.pubKey.toHexKey(),
packageName = signerPackageName,
contentResolver = appContext.contentResolver,
)
```
Create a single NostrClient for the entire application and control which relays it will access by
registering subscriptions and sending events. The pool will automatically changed based on filters +
outbox events.
You will need a coroutine scope to process events and if you are using OKHttp, we offer a basic
wrapper to create the socket connections themselves.
```kt
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient = OkHttpClient.Builder().build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
val client = NostrClient(socketBuilder, appScope)
```
If you want to auth, given a logged-in `signer`:
```kt
val authCoordinator = RelayAuthenticator(client, applicationIOScope) { challenge, relay ->
val authedEvent = RelayAuthEvent.create(relayUrl, challenge, signer)
client.sendIfExists(authedEvent, relay.url)
}
```
To manage subscriptions, the suggested approach is to use subscriptions in the Application class.
```kt
val metadataSub = RelayClientSubscription(
client = client,
filter = {
val filters = listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(signer.pubkey)
)
)
val signerOutboxRelays = listOfNotNull(
RelayUrlNormalizer.normalizeOrNull("wss://relay1.com"),
RelayUrlNormalizer.normalizeOrNull("wss://relay2.com")
)
signerOutboxRelays.associateWith { filters }
}
) { event ->
/* consume event */
}
```
## Contributing
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -111,7 +112,7 @@ class Amethyst : Application() {
val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, applicationIOScope)
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache, applicationIOScope)
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
@@ -119,7 +120,7 @@ class Amethyst : Application() {
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
// val logger = if (isDebug) RelaySpeedLogger(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
@@ -216,6 +216,7 @@ import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.Locale
import kotlin.collections.forEach
import kotlin.collections.ifEmpty
@OptIn(DelicateCoroutinesApi::class)
@Stable
@@ -305,7 +306,7 @@ class Account(
// Follows Relays
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, indexerRelayList, scope)
val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, indexerRelayList, scope)
// keeps a cache of the outbox relays for each author
val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow
@@ -538,7 +539,7 @@ class Account(
val zapRequest =
LnZapRequestEvent.create(
userHex = user.pubkeyHex,
relays = nip65RelayList.inboxFlow.value + user.inboxRelays(),
relays = nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
signer = signer,
message = message,
zapType = zapType,
@@ -659,7 +660,8 @@ class Account(
if (replyToAuthor == userProfile()) {
outboxRelays.flow.value
} else {
replyToAuthor.outboxRelays().ifEmpty { null }?.toSet()
replyToAuthor.inboxRelays()?.ifEmpty { null }?.toSet()
?: replyToAuthor.relaysBeingUsed.keys.ifEmpty { null }
?: cache.relayHints
.hintsForKey(replyToAuthor.pubkeyHex)
.ifEmpty { null }
@@ -685,7 +687,7 @@ class Account(
if (user == userProfile()) {
notificationRelays.flow.value
} else {
user.inboxRelays().ifEmpty { null }?.toSet()
user.inboxRelays()?.ifEmpty { null }?.toSet()
?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys)
}
@@ -738,10 +740,12 @@ class Account(
if (author == userProfile()) {
relayList.addAll(outboxRelays.flow.value)
} else {
relayList.addAll(
author.outboxRelays().ifEmpty { null }
?: cache.relayHints.hintsForKey(author.pubkeyHex),
)
val relays =
author.outboxRelays()?.ifEmpty { null }
?: author.relaysBeingUsed.keys.ifEmpty { null }
?: cache.relayHints.hintsForKey(author.pubkeyHex)
relayList.addAll(relays)
}
} else {
relayList.addAll(cache.relayHints.hintsForKey(event.pubKey))
@@ -1566,11 +1570,8 @@ class Account(
val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer)
val relayList =
dvmPublicKey
.inboxRelays()
.ifEmpty {
cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex) + dvmPublicKey.relaysBeingUsed.keys
}.toSet()
dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null }
?: (dvmPublicKey.relaysBeingUsed.keys + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
cache.justConsumeMyOwnEvent(request)
onReady(request)
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
@@ -49,7 +50,6 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -58,15 +58,12 @@ import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny
@@ -95,19 +92,9 @@ class AddressableNote(
override fun createdAt(): Long? {
val currentEvent = event
if (currentEvent == null) return null
val publishedAt =
when (currentEvent) {
is LongTextNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is WikiNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is VideoEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is ClassifiedsEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
else -> Long.MAX_VALUE
}
return minOf(publishedAt, currentEvent.createdAt)
if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt
return currentEvent.createdAt
}
fun dTag(): String = address.dTag
@@ -202,15 +189,15 @@ open class Note(
}
fun relayUrls(): List<NormalizedRelayUrl> {
val authorRelay = author?.relayHints()?.ifEmpty { null }
val authorRelay = author?.relayHints() ?: emptyList()
return authorRelay ?: relays
return authorRelay + relays
}
fun relayUrlsForReactions(): List<NormalizedRelayUrl> {
val authorRelay = author?.inboxRelays()?.ifEmpty { null }
val authorRelay = author?.inboxRelays() ?: emptyList()
return authorRelay ?: relays
return authorRelay + relays
}
fun relayHintUrl(): NormalizedRelayUrl? {
@@ -78,11 +78,11 @@ class User(
fun toNProfile() = NProfile.create(pubkeyHex, relayHints())
fun outboxRelays() = authorRelayList()?.writeRelaysNorm() ?: listOfNotNull(latestMetadataRelay)
fun outboxRelays() = authorRelayList()?.writeRelaysNorm()
fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay)
fun inboxRelays() = authorRelayList()?.readRelaysNorm() ?: listOfNotNull(latestMetadataRelay)
fun inboxRelays() = authorRelayList()?.readRelaysNorm()
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.model.serverList
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -41,7 +40,6 @@ class MergedFollowPlusMineRelayListsState(
val nip65RelayList: Nip65RelayListState,
val privateOutboxRelayList: PrivateStorageRelayListState,
val localRelayList: LocalRelayListState,
val broadcastRelayList: BroadcastRelayListState,
val indexerRelayList: IndexerRelayListState,
val scope: CoroutineScope,
) {
@@ -55,7 +53,6 @@ class MergedFollowPlusMineRelayListsState(
nip65RelayList.inboxFlow,
privateOutboxRelayList.flow,
localRelayList.flow,
broadcastRelayList.flow,
indexerRelayList.flow,
),
::mergeLists,
@@ -68,7 +65,6 @@ class MergedFollowPlusMineRelayListsState(
nip65RelayList.inboxFlow.value,
privateOutboxRelayList.flow.value,
localRelayList.flow.value,
broadcastRelayList.flow.value,
indexerRelayList.flow.value,
),
),
@@ -84,7 +80,6 @@ class MergedFollowPlusMineRelayListsState(
nip65RelayList.inboxFlow.value,
privateOutboxRelayList.flow.value,
localRelayList.flow.value,
broadcastRelayList.flow.value,
indexerRelayList.flow.value,
),
),
@@ -40,6 +40,8 @@ class OkHttpWebSocket(
fun buildRequest() = Request.Builder().url(url.url).build()
override fun needsReconnect(): Boolean {
if (socket == null) return true
val myUsingOkHttp = usingOkHttp
if (myUsingOkHttp == null) return true
@@ -89,13 +91,19 @@ class OkHttpWebSocket(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) = out.onClosed(code, reason)
) {
socket = null
out.onClosed(code, reason)
}
override fun onFailure(
webSocket: okhttp3.WebSocket,
t: Throwable,
response: Response?,
) = out.onFailure(t, response?.code, response?.message)
) {
socket = null
out.onFailure(t, response?.code, response?.message)
}
}
class Builder(
@@ -111,6 +119,7 @@ class OkHttpWebSocket(
override fun disconnect() {
// uses cancel to kill the SEND stack that might be waiting
socket?.cancel()
socket = null
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
@@ -27,12 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
class CacheClientConnector(
val client: NostrClient,
val cache: LocalCache,
val scope: CoroutineScope,
) {
val receiver =
EventCollector(client) { event, relay ->
@@ -77,7 +77,7 @@ class AccountNotificationsEoseFromInboxRelaysManager(
}
},
key.account.scope.launch(Dispatchers.Default) {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
@@ -27,12 +27,12 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class AccountNotificationsEoseFromRandomRelaysManager(
@@ -49,11 +49,18 @@ class AccountNotificationsEoseFromRandomRelaysManager(
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? =
(key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap {
val since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo()
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)
): List<RelayBasedFilter>? {
// only loads this after the feed is built
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
return if (defaultSince != null) {
(key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap {
val since = since?.get(it)?.time ?: defaultSince
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)
}
} else {
emptyList()
}
}
val userJobMap = mutableMapOf<User, List<Job>>()
@@ -69,6 +76,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.Default) {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
@@ -0,0 +1,327 @@
/**
* 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.components
import android.content.Context
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Configuration for zap button behavior and appearance
*/
data class ZapButtonConfig(
val grayTint: Color = Color.Gray,
val iconSize: Dp = Size35dp,
val iconSizeModifier: Modifier = Size20Modifier,
val animationModifier: Modifier = Size14Modifier,
val showUserFinderSubscription: Boolean = false,
val zapAmountChoices: List<Long>? = null,
val thankYouText: String? = null,
val buttonText: String? = null,
)
/**
* Callbacks for zap button events
*/
data class ZapButtonCallbacks(
val onZapComplete: ((Boolean) -> Unit)? = null,
)
@Composable
fun ReusableZapButton(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
config: ZapButtonConfig = ZapButtonConfig(),
callbacks: ZapButtonCallbacks = ZapButtonCallbacks(),
) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
var wantsToPay by remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(persistentListOf())
}
// Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons)
if (config.showUserFinderSubscription) {
baseNote.author?.let { author ->
UserFinderFilterAssemblerSubscription(author, accountViewModel)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
var hasZapped by remember { mutableStateOf(false) }
Button(
onClick = {
handleZapClick(
baseNote = baseNote,
accountViewModel = accountViewModel,
context = context,
zapAmountChoices = config.zapAmountChoices,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZappingProgress = { progress ->
scope.launch { zappingProgress = progress }
},
onMultipleChoices = { options ->
wantsToZap = options.toImmutableList()
},
onError = { _, message, toUser ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser)
}
},
onPayViaIntent = { wantsToPay = it },
)
},
modifier = Modifier.fillMaxWidth(),
) {
wantsToZap?.let { zapAmountChoices ->
ZapAmountChoicePopup(
baseNote = baseNote,
zapAmountChoices = zapAmountChoices,
popupYOffset = config.iconSize,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = null
zappingProgress = 0f
},
onChangeAmount = {
wantsToZap = null
},
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = {
scope.launch(Dispatchers.Main) { zappingProgress = it }
},
onPayViaIntent = { wantsToPay = it },
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = { wantsToPay = persistentListOf() },
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
)
}
// Zap Icon and Progress
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = config.iconSizeModifier,
) {
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(ModifierWidth3dp)
val animatedProgress by animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "ZapIconIndicator",
)
ObserveZapIcon(
baseNote,
accountViewModel,
zapStartingTime,
) { wasZappedByLoggedInUser ->
CrossfadeIfEnabled(
targetState = wasZappedByLoggedInUser.value,
label = "ZapIcon",
accountViewModel = accountViewModel,
) {
if (it) {
ZappedIcon(config.iconSizeModifier)
} else {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = config.animationModifier,
strokeWidth = 2.dp,
color = config.grayTint,
)
}
}
}
} else {
ObserveZapIcon(
baseNote,
accountViewModel,
) { wasZappedByLoggedInUser ->
LaunchedEffect(wasZappedByLoggedInUser.value) {
hasZapped = wasZappedByLoggedInUser.value
callbacks.onZapComplete?.invoke(wasZappedByLoggedInUser.value)
if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) {
delay(1000)
accountViewModel.markDonatedInThisVersion()
}
}
CrossfadeIfEnabled(
targetState = wasZappedByLoggedInUser.value,
label = "ZapIcon",
accountViewModel = accountViewModel,
) {
if (it) {
ZappedIcon(config.iconSizeModifier)
} else {
ZapIcon(config.iconSizeModifier, config.grayTint)
}
}
}
}
}
val displayText =
when {
hasZapped -> config.thankYouText ?: stringRes(id = R.string.thank_you)
config.buttonText != null -> config.buttonText
else -> stringRes(id = R.string.donate_now)
}
Text(text = displayText)
}
}
private fun handleZapClick(
baseNote: Note,
accountViewModel: AccountViewModel,
context: Context,
zapAmountChoices: List<Long>?,
onZapStarts: () -> Unit,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: (List<Long>) -> Unit,
onError: (String, String, User?) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
if (baseNote.isDraft()) {
accountViewModel.toastManager.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_zap_to_a_draft_note,
)
return
}
val choices = zapAmountChoices ?: accountViewModel.zapAmountChoices()
if (choices.isEmpty()) {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_dialog_zap_error),
stringRes(context, R.string.no_zap_amount_setup_long_press_to_change),
)
} else if (!accountViewModel.isWriteable()) {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_dialog_zap_error),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_send_zaps),
)
} else if (choices.size == 1) {
val amount = choices.first()
if (amount > 1100 || zapAmountChoices != null) {
onZapStarts()
accountViewModel.zap(
baseNote,
amount * 1000,
null,
"",
context,
showErrorIfNoLnAddress = false,
onError = onError,
onProgress = { onZappingProgress(it) },
onPayViaIntent = onPayViaIntent,
)
} else {
onMultipleChoices(listOf(1000L, 5_000L, 10_000L))
}
} else {
if (choices.any { it > 1100 } || zapAmountChoices != null) {
onMultipleChoices(choices)
} else {
onMultipleChoices(listOf(1000L, 5_000L, 10_000L))
}
}
}
@@ -50,7 +50,7 @@ class FeedContentState(
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
var scrollToTopPending = false
private var lastFeedKey: Any? = null
@@ -59,14 +59,14 @@ class FeedContentState(
val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow<Long?>(null)
fun sendToTop() {
if (scrolltoTopPending) return
if (scrollToTopPending) return
scrolltoTopPending = true
scrollToTopPending = true
viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) }
}
suspend fun sentToTop() {
scrolltoTopPending = false
scrollToTopPending = false
}
private fun refresh() {
@@ -107,9 +107,10 @@ class FeedContentState(
private fun updateFeed(notes: ImmutableList<Note>) {
if (notes.size >= localFilter.limit()) {
val lastNomeTime = notes.lastOrNull { it.event != null }?.createdAt()
if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) {
lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNomeTime)
// feeds might not be sorted by created at, so full search
val lastNoteTime = notes.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE }
if (lastNoteTime != lastNoteCreatedAtWhenFullyLoaded.value) {
lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNoteTime)
}
}
@@ -128,7 +129,8 @@ class FeedContentState(
fun deleteFromFeed(deletedNotes: Set<Note>) {
val feed = _feedContent.value
if (feed is FeedState.Loaded) {
updateFeed((feed.feed.value.list - deletedNotes).toImmutableList())
val notes = (feed.feed.value.list - deletedNotes).toImmutableList()
updateFeed(notes)
}
}
@@ -37,7 +37,7 @@ fun WatchScrollToTop(
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && feedContentState.scrolltoTopPending) {
if (scrollToTop > 0 && feedContentState.scrollToTopPending) {
listState.scrollToItem(index = 0)
feedContentState.sentToTop()
}
@@ -52,7 +52,7 @@ fun WatchScrollToTop(
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && feedContentState.scrolltoTopPending) {
if (scrollToTop > 0 && feedContentState.scrollToTopPending) {
listState.scrollToItem(index = 0)
feedContentState.sentToTop()
}
@@ -82,7 +82,7 @@ fun WatchScrollToTop(
val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && videoFeedContentState.scrolltoTopPending) {
if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) {
pagerState.scrollToPage(page = 0)
videoFeedContentState.sentToTop()
}
@@ -20,84 +20,54 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import android.content.Context
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.ReusableZapButton
import com.vitorpamplona.amethyst.ui.components.ZapButtonConfig
import com.vitorpamplona.amethyst.ui.components.appendLink
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@Preview
@@ -286,214 +256,17 @@ fun ZapDonationButton(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = Size35dp,
iconSizeModifier: Modifier = Size20Modifier,
animationModifier: Modifier = Size14Modifier,
nav: INav,
) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
var wantsToPay by
remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf(),
)
}
val config =
ZapButtonConfig(
grayTint = grayTint,
)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
var hasZapped by remember { mutableStateOf(false) }
Button(
onClick = {
customZapClick(
baseNote,
accountViewModel,
context,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZappingProgress = { progress: Float ->
scope.launch { zappingProgress = progress }
},
onMultipleChoices = { options -> wantsToZap = options.toImmutableList() },
onError = { _, message, toUser ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser)
}
},
onPayViaIntent = { wantsToPay = it },
)
},
modifier = Modifier.fillMaxWidth(),
) {
wantsToZap?.let {
ZapAmountChoicePopup(
baseNote = baseNote,
zapAmountChoices = it,
popupYOffset = iconSize,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = null
zappingProgress = 0f
},
onChangeAmount = {
wantsToZap = null
},
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = {
scope.launch(Dispatchers.Main) { zappingProgress = it }
},
onPayViaIntent = { wantsToPay = it },
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = { wantsToPay = persistentListOf() },
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = iconSizeModifier,
) {
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(ModifierWidth3dp)
val animatedProgress by animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "ZapIconIndicator",
)
ObserveZapIcon(
baseNote,
accountViewModel,
zapStartingTime,
) { wasZappedByLoggedInUser ->
CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = animationModifier,
strokeWidth = 2.dp,
color = grayTint,
)
}
}
}
} else {
ObserveZapIcon(
baseNote,
accountViewModel,
) { wasZappedByLoggedInUser ->
LaunchedEffect(wasZappedByLoggedInUser.value) {
hasZapped = wasZappedByLoggedInUser.value
if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) {
delay(1000)
accountViewModel.markDonatedInThisVersion()
}
}
CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
ZapIcon(iconSizeModifier, grayTint)
}
}
}
}
}
if (hasZapped) {
Text(text = stringRes(id = R.string.thank_you))
} else {
Text(text = stringRes(id = R.string.donate_now))
}
}
}
fun customZapClick(
baseNote: Note,
accountViewModel: AccountViewModel,
context: Context,
onZapStarts: () -> Unit,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: (List<Long>) -> Unit,
onError: (String, String, User?) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
if (baseNote.isDraft()) {
accountViewModel.toastManager.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_zap_to_a_draft_note,
)
return
}
val choices = accountViewModel.zapAmountChoices()
if (choices.isEmpty()) {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_dialog_zap_error),
stringRes(context, R.string.no_zap_amount_setup_long_press_to_change),
)
} else if (!accountViewModel.isWriteable()) {
accountViewModel.toastManager.toast(
stringRes(context, R.string.error_dialog_zap_error),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_send_zaps),
)
} else if (choices.size == 1) {
val amount = choices.first()
if (amount > 1100) {
onZapStarts()
accountViewModel.zap(
baseNote,
amount * 1000,
null,
"",
context,
showErrorIfNoLnAddress = false,
onError = onError,
onProgress = { onZappingProgress(it) },
onPayViaIntent = onPayViaIntent,
)
} else {
onMultipleChoices(listOf(1000L, 5_000L, 10_000L))
// recommends amounts for a monthly release.
}
} else if (choices.size > 1) {
if (choices.any { it > 1100 }) {
onMultipleChoices(choices)
} else {
onMultipleChoices(listOf(1000L, 5_000L, 10_000L))
}
}
ReusableZapButton(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
config = config,
)
}
@@ -91,16 +91,16 @@ fun RenderAppDefinition(
withContext(Dispatchers.Default) { metadata = noteEvent.appMetaData() }
}
metadata?.let {
metadata?.let { theAppMetadata ->
Box {
val clipboardManager = LocalClipboardManager.current
val uri = LocalUriHandler.current
if (!it.banner.isNullOrBlank()) {
if (!theAppMetadata.banner.isNullOrBlank()) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
AsyncImage(
model = it.banner,
model = theAppMetadata.banner,
contentDescription = stringRes(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier =
@@ -109,13 +109,13 @@ fun RenderAppDefinition(
.height(125.dp)
.combinedClickable(
onClick = {},
onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) },
onLongClick = { clipboardManager.setText(AnnotatedString(theAppMetadata.banner!!)) },
),
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(it.banner!!),
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
@@ -144,12 +144,11 @@ fun RenderAppDefinition(
verticalAlignment = Alignment.Bottom,
) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box(Modifier.size(100.dp)) {
it.picture?.let { picture ->
theAppMetadata.picture?.let { picture ->
AsyncImage(
model = picture,
contentDescription = it.name,
contentDescription = theAppMetadata.name,
contentScale = ContentScale.FillWidth,
modifier =
Modifier
@@ -165,15 +164,15 @@ fun RenderAppDefinition(
onLongClick = { clipboardManager.setText(AnnotatedString(picture)) },
),
)
}
}
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(it.banner!!),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
}
}
}
Spacer(Modifier.weight(1f))
@@ -186,7 +185,7 @@ fun RenderAppDefinition(
) {}
}
val name = remember(it) { it.anyName() }
val name = remember(theAppMetadata) { theAppMetadata.anyName() }
name?.let {
Row(
verticalAlignment = Alignment.Bottom,
@@ -204,7 +203,7 @@ fun RenderAppDefinition(
}
}
val website = remember(it) { it.website }
val website = remember(theAppMetadata) { theAppMetadata.website }
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
@@ -217,7 +216,7 @@ fun RenderAppDefinition(
}
}
it.about?.let {
theAppMetadata.about?.let {
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp),
) {
@@ -86,7 +86,7 @@ class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(
key.feedStates.discoverLive.lastNoteCreatedAtWhenFullyLoaded,
) {
Any()
}.sample(1000).collectLatest {
}.sample(5000).collectLatest {
invalidateFilters()
}
},
@@ -89,7 +89,7 @@ class DiscoveryLongFormClassifiedsAndDVMSubAssembler1(
key.feedStates.discoverMarketplace.lastNoteCreatedAtWhenFullyLoaded,
) {
Any()
}.sample(1000).collectLatest {
}.sample(5000).collectLatest {
invalidateFilters()
}
},
@@ -85,7 +85,7 @@ class DiscoveryPublicChatsAndCommunitiesSubAssembler3(
key.feedStates.discoverCommunities.lastNoteCreatedAtWhenFullyLoaded,
) {
Any()
}.sample(1000).collectLatest {
}.sample(5000).collectLatest {
invalidateFilters()
}
},
@@ -20,30 +20,21 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
import androidx.compose.animation.core.animateFloatAsState
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomStart
@@ -61,25 +52,18 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.ReusableZapButton
import com.vitorpamplona.amethyst.ui.components.ZapButtonConfig
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.note.elements.customZapClick
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
@@ -88,9 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DVMCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal.NIP90ContentDiscoveryFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
@@ -98,12 +80,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun DvmContentDiscoveryScreen(
@@ -453,160 +429,23 @@ fun ZapDVMButton(
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = Size35dp,
iconSizeModifier: Modifier = Size20Modifier,
animationSize: Dp = 14.dp,
nav: INav,
) {
val noteAuthor = baseNote.author ?: return
val config =
ZapButtonConfig(
grayTint = grayTint,
iconSize = iconSize,
showUserFinderSubscription = true,
zapAmountChoices = listOf(amount / 1000),
buttonText = "Zap ${(amount / 1000)} sats to the DVM",
)
var wantsToZap by remember { mutableStateOf<List<Long>?>(null) }
var wantsToPay by
remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf(),
)
}
// Makes sure the user is loaded to get his ln address ahead of time.
UserFinderFilterAssemblerSubscription(noteAuthor, accountViewModel)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
var hasZapped by remember { mutableStateOf(false) }
Button(
onClick = {
customZapClick(
baseNote,
accountViewModel,
context,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZappingProgress = { progress: Float ->
scope.launch { zappingProgress = progress }
},
onMultipleChoices = { options -> wantsToZap = options },
onError = { _, message, toUser ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser)
}
},
onPayViaIntent = { wantsToPay = it },
)
},
modifier = Modifier.fillMaxWidth(),
) {
if (wantsToZap != null) {
ZapAmountChoicePopup(
baseNote = baseNote,
zapAmountChoices = persistentListOf(amount / 1000),
popupYOffset = iconSize,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = null
zappingProgress = 0f
},
onChangeAmount = {
wantsToZap = null
},
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
},
onProgress = {
scope.launch(Dispatchers.Main) { zappingProgress = it }
},
onPayViaIntent = { wantsToPay = it },
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = { wantsToPay = persistentListOf() },
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
justShowError = {
scope.launch {
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it)
}
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = iconSizeModifier,
) {
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(ModifierWidth3dp)
val animatedProgress by animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "ZapIconIndicator",
)
ObserveZapIcon(
baseNote,
accountViewModel,
zapStartingTime,
) { wasZappedByLoggedInUser ->
CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp,
color = grayTint,
)
}
}
}
} else {
ObserveZapIcon(
baseNote,
accountViewModel,
) { wasZappedByLoggedInUser ->
LaunchedEffect(wasZappedByLoggedInUser.value) {
hasZapped = wasZappedByLoggedInUser.value
if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) {
delay(1000)
accountViewModel.markDonatedInThisVersion()
}
}
CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
ZapIcon(iconSizeModifier, grayTint)
}
}
}
}
}
if (hasZapped) {
Text(text = stringRes(id = R.string.thank_you))
} else {
Text(text = "Zap " + (amount / 1000).toString() + " sats to the DVM") // stringRes(id = R.string.donate_now))
}
}
ReusableZapButton(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
config = config,
)
}
@Composable
@@ -102,12 +102,12 @@ class HomeOutboxEventsEoseManager(
}
},
key.account.scope.launch(Dispatchers.Default) {
key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest {
key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.Default) {
key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest {
key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
@@ -78,6 +78,9 @@ class CardFeedContentState(
val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow<Long?>(null)
private var lastAccount: Account? = null
private var lastNotes: Set<Note>? = null
fun sendToTop() {
if (scrolltoTopPending) return
@@ -89,8 +92,14 @@ class CardFeedContentState(
scrolltoTopPending = false
}
private var lastAccount: Account? = null
private var lastNotes: Set<Note>? = null
fun visibleNotes(): List<Card> {
val currentState = _feedContent.value
return if (currentState is CardFeedState.Loaded) {
currentState.feed.value.list
} else {
emptyList()
}
}
fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value
@@ -305,8 +314,16 @@ class CardFeedContentState(
private fun updateFeed(notes: ImmutableList<Card>) {
if (notes.size >= localFilter.limit()) {
val lastNomeTime = notes.lastOrNull()?.createdAt()
if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) {
val lastNoteTime =
notes.minOfOrNull {
val createdAt = it.createdAt()
if (createdAt > 0L) {
createdAt
} else {
Long.MAX_VALUE
}
}
if (lastNoteTime != lastNoteCreatedAtWhenFullyLoaded.value) {
lastNoteCreatedAtWhenFullyLoaded.tryEmit(notes.lastOrNull()?.createdAt())
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -31,8 +32,13 @@ val UserProfileFollowersKinds = listOf(ContactListEvent.KIND)
fun filterUserProfileFollowers(
user: User,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
user.inboxRelays().map {
): List<RelayBasedFilter> {
val relays =
user.inboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map {
RelayBasedFilter(
relay = it,
filter =
@@ -43,3 +49,4 @@ fun filterUserProfileFollowers(
),
)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
@@ -40,8 +41,13 @@ val UserProfileMediaKinds =
fun filterUserProfileMedia(
user: User,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
user.outboxRelays().map { relay ->
): List<RelayBasedFilter> {
val relays =
user.outboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
@@ -53,3 +59,4 @@ fun filterUserProfileMedia(
),
)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -66,9 +67,13 @@ val UserProfilePostKinds2 =
fun filterUserProfilePosts(
user: User,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
user
.outboxRelays()
): List<RelayBasedFilter> {
val relays =
user.outboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays
.map { relay ->
listOf(
RelayBasedFilter(
@@ -93,3 +98,4 @@ fun filterUserProfilePosts(
),
)
}.flatten()
}
@@ -20,19 +20,26 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlin.collections.ifEmpty
val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND)
fun filterUserProfileZapsReceived(
user: User,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
user.inboxRelays().map { relay ->
): List<RelayBasedFilter> {
val relays =
user.inboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
@@ -44,3 +51,4 @@ fun filterUserProfileZapsReceived(
),
)
}
}
@@ -20,11 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlin.collections.ifEmpty
class UserProfileMetadataFilterSubAssembler(
client: NostrClient,
@@ -37,7 +39,12 @@ class UserProfileMetadataFilterSubAssembler(
val userPerRelay =
mapOfSet {
keys.mapTo(mutableSetOf()) { key -> key.user }.forEach { user ->
user.outboxRelays().forEach { relay ->
val relays =
user.outboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
relays.forEach { relay ->
add(relay, user.pubkeyHex)
}
}
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassembl
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -63,8 +64,8 @@ class VideoOutboxEventsFilterSubAssembler(
is AllCommunitiesTopNavPerRelayFilterSet -> filterPictureAndVideoByAllCommunities(feedSettings, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterPictureAndVideoByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince ?: TimeUtils.oneWeekAgo())
is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince ?: TimeUtils.oneMonthAgo())
is LocationTopNavPerRelayFilterSet -> filterPictureAndVideoByGeohash(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPictureAndVideoByCommunity(feedSettings, since, defaultSince)
+3 -3
View File
@@ -13,12 +13,12 @@ benchmark = "1.4.0"
benchmarkJunit4 = "1.4.0"
biometricKtx = "1.2.0-alpha05"
coil = "3.3.0"
composeBom = "2025.07.00"
coreKtx = "1.16.0"
composeBom = "2025.08.00"
coreKtx = "1.17.0"
datastore = "1.1.7"
espressoCore = "3.7.0"
firebaseBom = "34.1.0"
fragmentKtx = "1.8.8"
fragmentKtx = "1.8.9"
gms = "4.4.3"
jacksonModuleKotlin = "2.19.2"
jna = "5.17.0"
+3
View File
@@ -76,6 +76,9 @@ dependencies {
// Normalizes URLs
api libs.rfc3986.normalizer
// Websockets API
implementation libs.okhttp
testImplementation libs.junit
testImplementation libs.secp256k1.kmp.jni.jvm
androidTestImplementation platform(libs.androidx.compose.bom)
@@ -122,8 +122,6 @@ class NostrClient(
}
}
fun allAvailableRelays() = relayPool.getAll()
// Reconnects all relays that may have disconnected
fun connect() {
isActive = true
@@ -138,10 +136,7 @@ class NostrClient(
@Synchronized
fun reconnect(onlyIfChanged: Boolean = false) {
if (onlyIfChanged) {
relayPool.getAllNeedsToReconnect().forEach {
it.disconnect()
}
relayPool.connect()
relayPool.reconnectIfNeedsToORIfItIsTime()
} else {
relayPool.disconnect()
relayPool.connect()
@@ -236,6 +231,9 @@ class NostrClient(
relayPool.connectIfDisconnected(relay)
}
}
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -269,6 +267,9 @@ class NostrClient(
relayPool.connectIfDisconnected(relay)
}
}
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -278,6 +279,9 @@ class NostrClient(
) {
if (isActive) {
relayPool.getRelay(connectedRelay)?.send(event)
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -288,6 +292,9 @@ class NostrClient(
eventOutbox.markAsSending(event, relayList)
if (isActive) {
relayPool.send(event, relayList)
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
}
}
@@ -0,0 +1,60 @@
/**
* 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
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.RandomInstance
class NostrClientSubscription(
val client: NostrClient,
val filter: () -> Map<NormalizedRelayUrl, List<Filter>> = { emptyMap() },
val onEvent: (event: Event) -> Unit = {},
) : IRelayClientListener {
private val subId = RandomInstance.randomChars(10)
init {
client.subscribe(this)
}
override fun onEvent(
relay: IRelayClient,
subId: String,
event: Event,
arrivalTime: Long,
afterEOSE: Boolean,
) {
if (this.subId == subId) {
onEvent(event)
}
}
/**
* Creates or Updates the filter with relays. This method should be called
* everytime the filter changes.
*/
fun updateFilter() = client.openReqSubscription(subId, filter())
fun closeSubscription() = client.close(subId)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.LargeCache
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -70,16 +71,24 @@ class RelayPool(
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url)
fun getAll() = statusFlow.value.connected
var lastReconnectCall = TimeUtils.now()
fun getAllNeedsToReconnect() = relays.filter { url, relay -> relay.needsToReconnect() }
fun reconnectsRelaysThatNeedTo() {
relays.forEach { url, relay ->
if (relay.needsToReconnect()) {
relay.disconnect()
relay.connect()
fun reconnectIfNeedsToORIfItIsTime() {
if (lastReconnectCall < TimeUtils.tenSecondsAgo()) {
relays.forEach { url, relay ->
if (relay.isConnected()) {
if (relay.needsToReconnect()) {
// network has changed, force reconnect
relay.disconnect()
relay.connect()
}
} else {
// relay is not connected. Connect if it is time
relay.connectAndSyncFiltersIfDisconnected()
}
}
lastReconnectCall = TimeUtils.now()
}
}
@@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_MSECS
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
@@ -84,7 +83,7 @@ open class BasicRelayClient(
) : IRelayClient {
companion object {
// waits 3 minutes to reconnect once things fail
const val DELAY_TO_RECONNECT_IN_MSECS = 500L
const val DELAY_TO_RECONNECT_IN_SECS = 1
const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\""
}
@@ -94,8 +93,8 @@ open class BasicRelayClient(
private var isReady: Boolean = false
private var usingCompression: Boolean = false
private var lastConnectTentative: Long = 0L // the beginning of time.
private var delayToConnect = DELAY_TO_RECONNECT_IN_MSECS
private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time.
private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
@@ -138,7 +137,7 @@ open class BasicRelayClient(
Log.d(logTag, "Connecting...")
lastConnectTentative = TimeUtils.now()
lastConnectTentativeInSeconds = TimeUtils.now()
socket = socketBuilder.build(url, MyWebsocketListener(onConnected))
socket?.connect()
@@ -207,22 +206,30 @@ open class BasicRelayClient(
code: Int?,
response: String?,
) {
socket?.disconnect() // 1000, "Normal close"
// socket is already closed
// socket?.disconnect()
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}")
if (socket == null) {
// comes from the disconnect action.
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
} else {
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}")
}
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w(logTag, "OnFailure $code $response ${t.message}")
if (code == 403 || code == 502 || code == 503 || code == 402 || code == 410 || t.message == "SOCKS: Host unreachable") {
dontTryAgainForALongTime()
}
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
listener.onError(this@BasicRelayClient, "", Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t))
}
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w(logTag, "OnFailure $code $response ${t.message} $socket")
listener.onError(
this@BasicRelayClient,
"",
Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t),
)
}
}
@@ -261,7 +268,7 @@ open class BasicRelayClient(
this.usingCompression = usingCompression
// resets any extra delays added during on offline state
this.delayToConnect = DELAY_TO_RECONNECT_IN_MSECS
this.delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
stats.pingInMs = pingInMs
}
@@ -320,7 +327,7 @@ open class BasicRelayClient(
}
private fun processAuth(msg: AuthMessage) {
// Log.d(logTag, "Auth $newMessage")
Log.d(logTag, "Auth ${msg.challenge}")
listener.onAuth(this@BasicRelayClient, msg.challenge)
}
@@ -344,8 +351,8 @@ open class BasicRelayClient(
override fun disconnect() {
Log.d(logTag, "Disconnecting...")
lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested.
delayToConnect = DELAY_TO_RECONNECT_IN_MSECS
lastConnectTentativeInSeconds = 0L // this is not an error, so prepare to reconnect as soon as requested.
delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
socket?.disconnect()
socket = null
isReady = false
@@ -372,12 +379,7 @@ open class BasicRelayClient(
}
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
delayToConnect = delayToConnect * 2
// sends all filters after connection is successful.
connect()
}
connectAndSyncFiltersIfDisconnected()
}
}
@@ -393,25 +395,30 @@ open class BasicRelayClient(
}
}
} else {
connectAndSyncFiltersIfDisconnected()
}
}
override fun connectAndSyncFiltersIfDisconnected() {
if (!isConnectionStarted() && !connectingMutex.get()) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
// sends all filters after connection is successful.
delayToConnect = delayToConnect * 2
if (TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) {
upRelayDelayToConnect()
connect()
}
}
}
override fun connectAndSyncFiltersIfDisconnected() {
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
delayToConnect = delayToConnect * 2
connect()
}
fun upRelayDelayToConnect() {
if (delayToConnectInSeconds < TimeUtils.FIVE_MINUTES) {
delayToConnectInSeconds = delayToConnectInSeconds * 2
}
}
fun dontTryAgainForALongTime() {
delayToConnectInSeconds = TimeUtils.ONE_DAY
}
override fun send(event: Event) {
listener.onBeforeSend(this@BasicRelayClient, event)
@@ -439,8 +446,7 @@ open class BasicRelayClient(
writeToSocket(EventCmd.Companion.toJson(event))
}
} else {
// automatically sends all filters after connection is successful.
connect()
connectAndSyncFiltersIfDisconnected()
}
}
@@ -29,9 +29,6 @@ class NoticeMessage(
const val LABEL = "NOTICE"
@JvmStatic
fun parse(msgArray: JsonNode): NoticeMessage =
NoticeMessage(
msgArray.get(1).asText(),
)
fun parse(msgArray: JsonNode) = NoticeMessage(msgArray.get(1).asText())
}
}
@@ -36,7 +36,7 @@ fun NormalizedRelayUrl.toHttp() =
if (url.startsWith("wss://")) {
"https${url.drop(3)}"
} else if (url.startsWith("ws://")) {
"https${url.drop(2)}"
"http${url.drop(2)}"
} else {
"https://$url"
}
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class BasicOkHttpWebSocket(
val url: NormalizedRelayUrl,
val httpClient: OkHttpClient,
val out: WebSocketListener,
) : WebSocket {
private var socket: okhttp3.WebSocket? = null
override fun needsReconnect() = socket == null
override fun connect() {
val request = Request.Builder().url(url.url).build()
val listener =
object : okhttp3.WebSocketListener() {
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: Response,
) = out.onOpen(
response.receivedResponseAtMillis - response.sentRequestAtMillis,
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) = out.onMessage(text)
override fun onClosing(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) = out.onClosing(code, reason)
override fun onClosed(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) = out.onClosed(code, reason)
override fun onFailure(
webSocket: okhttp3.WebSocket,
t: Throwable,
r: Response?,
) = out.onFailure(t, r?.code, r?.message)
}
socket = httpClient.newWebSocket(request, listener)
}
override fun disconnect() {
socket?.cancel()
socket = null
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
class Builder(
val httpClient: OkHttpClient,
) : WebsocketBuilder {
// Called when connecting.
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = BasicOkHttpWebSocket(url, httpClient, out)
}
}
@@ -0,0 +1,25 @@
/**
* 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.tags.publishedAt
interface PublishedAtProvider {
fun publishedAt(): Long?
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
import com.vitorpamplona.quartz.nip19Bech32.addressHints
@@ -68,6 +69,7 @@ class LongTextNoteEvent(
EventHintProvider,
PubKeyHintProvider,
AddressHintProvider,
PublishedAtProvider,
RootScope,
SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
@@ -130,7 +132,7 @@ class LongTextNoteEvent(
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
companion object {
const val KIND = 30023
@@ -32,7 +32,16 @@ class PublishedAtTag {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
val timestamp = tag[1].toLongOrNull()
if (timestamp == null) return null
if (timestamp > 3_000_000_000) {
// like in milliseconds
return timestamp / 1000
} else {
return timestamp
}
}
@JvmStatic
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -62,6 +63,7 @@ class WikiNoteEvent(
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider,
PublishedAtProvider,
SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
@@ -127,7 +129,7 @@ class WikiNoteEvent(
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun publishedAt() =
override fun publishedAt() =
try {
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) {
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
@@ -45,12 +46,13 @@ abstract class VideoEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
PublishedAtProvider,
RootScope {
@Transient var iMetas: List<VideoMeta>? = null
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse)
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.isTaggedKind
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip31Alts.alt
@@ -46,7 +47,8 @@ class AppDefinitionEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider {
override fun countMemory(): Long = super.countMemory() + (cachedMetadata?.countMemory() ?: 8L)
@Transient private var cachedMetadata: AppMetadata? = null
@@ -77,7 +79,7 @@ class AppDefinitionEvent(
fun includeKind(kind: Int) = tags.isTaggedKind(kind)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
companion object {
const val KIND = 31990
@@ -113,8 +113,8 @@ class AppMetadata {
fun toJson() = assemble(this)
companion object {
fun assemble(data: AppMetadata) = JsonMapper.mapper.writeValueAsString(data)
fun assemble(data: AppMetadata): String = JsonMapper.mapper.writeValueAsString(data)
fun parse(content: String) = JsonMapper.mapper.readValue(content, AppMetadata::class.java)
fun parse(content: String): AppMetadata = JsonMapper.mapper.readValue(content, AppMetadata::class.java)
}
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
@@ -49,7 +50,8 @@ class ClassifiedsEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
@@ -68,7 +70,7 @@ class ClassifiedsEvent(
fun location() = tags.firstNotNullOfOrNull(LocationTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
fun categories() = tags.hashtags()
@@ -36,6 +36,8 @@ object TimeUtils {
fun tenSecondsFromNow() = now() + TEN_SECONDS
fun tenSecondsAgo() = now() - TEN_SECONDS
fun oneMinuteFromNow() = now() + ONE_MINUTE
fun oneMinuteAgo() = now() - ONE_MINUTE