mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge pull request #3844 from vitorpamplona/claude/code-quality-class-decoupling-rrt199
refactor: decouple LocalCache and Account god classes (behavior-preserving)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,580 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
|
||||
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
|
||||
* metadata updates, group messaging, and key-package publishing. MLS state
|
||||
* lives in [MarmotManager]; this class wires it to the account's signer, relay
|
||||
* client, and relay lists. Functions live here (not a ViewModel) so headless
|
||||
* callers - notification receivers, background workers - can drive them.
|
||||
*/
|
||||
class AccountMarmotActions(
|
||||
private val account: Account,
|
||||
) {
|
||||
/**
|
||||
* Resolve the relay set for a Marmot group. Prefer the relays carried in
|
||||
* the MLS GroupContext metadata so every member converges on the same
|
||||
* canonical set; fall back to the account's outbox relays if the group
|
||||
* has none (e.g. a group joined before MIP-01 metadata existed).
|
||||
*
|
||||
* Lives on Account (not AccountViewModel) so that headless callers —
|
||||
* notifications' BroadcastReceiver, background workers — can resolve
|
||||
* relays without spinning up a ViewModel.
|
||||
*/
|
||||
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
|
||||
val groupRelays =
|
||||
account.marmotManager
|
||||
?.groupMetadata(nostrGroupId)
|
||||
?.relays
|
||||
?.mapNotNull {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalizeOrNull(it)
|
||||
}?.toSet()
|
||||
return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a Marmot MLS group.
|
||||
* Encrypts the inner event and publishes the GroupEvent to group relays.
|
||||
*/
|
||||
suspend fun sendMarmotGroupMessage(
|
||||
nostrGroupId: HexKey,
|
||||
innerEvent: Event,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
Log.d("MarmotDbg") {
|
||||
"sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}… " +
|
||||
"→ ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
|
||||
}
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent)
|
||||
Log.d("MarmotDbg") {
|
||||
"sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…"
|
||||
}
|
||||
// Link the envelope to the inner message we just encrypted so relay
|
||||
// OK acceptances drill down to the note the chat renders (see
|
||||
// LocalCache.addRelayToNoteAndInners).
|
||||
outbound.signedEvent.innerEventId = innerEvent.id
|
||||
account.cache.justConsumeMyOwnEvent(outbound.signedEvent)
|
||||
// Sending a message moves the group out of "New Requests" into
|
||||
// "Known" — do this eagerly before relay round-trip so the UI
|
||||
// updates immediately.
|
||||
account.marmotGroupList.markAsKnown(nostrGroupId)
|
||||
if (groupRelays.isEmpty()) {
|
||||
Log.w("MarmotDbg") {
|
||||
"sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped"
|
||||
}
|
||||
}
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user's KeyPackage from relays and add them to a Marmot group.
|
||||
* Returns a status message describing the outcome.
|
||||
*/
|
||||
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
|
||||
suspend fun fetchKeyPackageAndAddMember(
|
||||
nostrGroupId: HexKey,
|
||||
memberPubKey: HexKey,
|
||||
): String {
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}…"
|
||||
}
|
||||
val manager = account.marmotManager ?: return "Error: Marmot not initialized"
|
||||
if (!account.isWriteable()) return "Error: Account is read-only"
|
||||
|
||||
// Per MIP-00, invitees advertise the relays that host their
|
||||
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
|
||||
// there first, then fall back to the invitee's NIP-65 outbox
|
||||
// (where KeyPackages typically also land), and finally union
|
||||
// with our own outbox so we still find packages that ended up
|
||||
// on a shared relay.
|
||||
val myOutbox = account.outboxRelays.flow.value
|
||||
val memberKeyPackageRelays =
|
||||
(
|
||||
account.cache
|
||||
.getAddressableNoteIfExists(
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
.createAddress(memberPubKey),
|
||||
)?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
)?.relays()?.toSet().orEmpty()
|
||||
val memberOutbox =
|
||||
account.cache
|
||||
.getOrCreateUser(memberPubKey)
|
||||
.outboxRelays()
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
val fetchRelays =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
|
||||
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
|
||||
}
|
||||
|
||||
val event =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchKeyPackage(account.client, memberPubKey, fetchRelays)
|
||||
|
||||
if (event == null) {
|
||||
Log.w("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)"
|
||||
}
|
||||
return "Error: No KeyPackage found for this user. They may not have published one yet."
|
||||
}
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…"
|
||||
}
|
||||
|
||||
val keyPackageBase64 = event.keyPackageBase64()
|
||||
if (keyPackageBase64.isBlank()) {
|
||||
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
|
||||
return "Error: KeyPackage event has empty content"
|
||||
}
|
||||
|
||||
// The relays embedded in the WelcomeEvent tell the new member
|
||||
// where to subscribe for subsequent GroupEvents. Use our own
|
||||
// outbox — that's where we will publish them.
|
||||
val groupRelays = myOutbox.toList()
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}"
|
||||
}
|
||||
|
||||
addMarmotGroupMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
keyPackageEvent = event,
|
||||
groupRelays = groupRelays,
|
||||
)
|
||||
|
||||
return "Success: Member added to group"
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a Marmot MLS group.
|
||||
* Publishes the commit GroupEvent, then sends the Welcome gift wrap.
|
||||
*/
|
||||
suspend fun addMarmotGroupMember(
|
||||
nostrGroupId: HexKey,
|
||||
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
|
||||
groupRelays: List<NormalizedRelayUrl>,
|
||||
) {
|
||||
val memberPubKey = keyPackageEvent.pubKey
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " +
|
||||
"groupRelays=${groupRelays.size}"
|
||||
}
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val (commitEvent, welcomeDelivery) =
|
||||
manager.addMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
keyPackageEvent = keyPackageEvent,
|
||||
relays = groupRelays,
|
||||
)
|
||||
|
||||
// The MLS commit has already been applied to the local group state —
|
||||
// surface the new member list in the chatroom now so observers (e.g.
|
||||
// MarmotGroupInfoScreen) update without waiting for our own commit to
|
||||
// loop back through the relay.
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
manager.syncMetadataTo(nostrGroupId, chatroom)
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}… " +
|
||||
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
|
||||
}
|
||||
|
||||
// Publish commit first (critical ordering)
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
|
||||
}
|
||||
account.client.publish(commitEvent.signedEvent, groupRelays.toSet())
|
||||
|
||||
// Then send the Welcome gift wrap to the new member.
|
||||
//
|
||||
// Use the same delivery path that NIP-17 DMs (kind:1059) take —
|
||||
// computeRelayListToBroadcast() — which has fallbacks for kind:10050
|
||||
// → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the
|
||||
// invitee, so this path is the one we know works. We also union
|
||||
// with our own outbox + the recipient's dmInboxRelays() as a
|
||||
// belt-and-braces measure in case the cache hasn't been hydrated
|
||||
// yet for this contact.
|
||||
if (welcomeDelivery != null) {
|
||||
val computed = account.broadcaster.computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)
|
||||
val recipientInbox =
|
||||
account.cache
|
||||
.getOrCreateUser(memberPubKey)
|
||||
.dmInboxRelays()
|
||||
.orEmpty()
|
||||
val relayList = computed + account.outboxRelays.flow.value + recipientInbox
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: welcome gift wrap relay sources " +
|
||||
"computeRelayListToBroadcast=${computed.size} myOutbox=${account.outboxRelays.flow.value.size} " +
|
||||
"recipientInbox=${recipientInbox.size} → union=${relayList.size}"
|
||||
}
|
||||
if (relayList.isEmpty()) {
|
||||
Log.w("MarmotDbg") {
|
||||
"addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped"
|
||||
}
|
||||
} else {
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}… " +
|
||||
"kind:${welcomeDelivery.giftWrapEvent.kind} → ${relayList.size} relay(s): ${relayList.map { it.url }}"
|
||||
}
|
||||
}
|
||||
account.client.publish(welcomeDelivery.giftWrapEvent, relayList)
|
||||
} else {
|
||||
Log.w("MarmotDbg") {
|
||||
"addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays where this account publishes kind:30443 KeyPackage events.
|
||||
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
|
||||
*/
|
||||
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value)
|
||||
|
||||
/**
|
||||
* Publish or rotate KeyPackage events.
|
||||
*/
|
||||
suspend fun publishMarmotKeyPackages() {
|
||||
val manager =
|
||||
account.marmotManager ?: run {
|
||||
Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" }
|
||||
return
|
||||
}
|
||||
if (!account.isWriteable()) {
|
||||
Log.w("MarmotDbg") { "publishMarmotKeyPackages: account is not writeable — no-op" }
|
||||
return
|
||||
}
|
||||
|
||||
val relays = keyPackagePublishRelays()
|
||||
val needsRotation = manager.needsKeyPackageRotation()
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackages: needsRotation=$needsRotation relays=${relays.size}"
|
||||
}
|
||||
|
||||
if (needsRotation) {
|
||||
val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList())
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackages: rotateConsumedKeyPackages produced ${rotatedEvents.size} event(s)"
|
||||
}
|
||||
rotatedEvents.forEach { event ->
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackages: publishing rotated kind:${event.kind} id=${event.id.take(8)}… " +
|
||||
"→ ${relays.size} relay(s): ${relays.map { it.url }}"
|
||||
}
|
||||
account.client.publish(event, relays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and publish initial KeyPackage for this account.
|
||||
*/
|
||||
suspend fun publishMarmotKeyPackage() {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val relays = keyPackagePublishRelays()
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}"
|
||||
}
|
||||
val event = manager.generateKeyPackageEvent(relays.toList())
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}…"
|
||||
}
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, relays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the local user has at least one active KeyPackage bundle and
|
||||
* a published KeyPackage event on relays. Called from [init] after
|
||||
* Marmot state has been restored from disk.
|
||||
*
|
||||
* - If [KeyPackageRotationManager] already has an active bundle (from
|
||||
* the persisted snapshot), we trust the previous session and do
|
||||
* nothing. The matching kind:30443 should already be on relays from
|
||||
* when the bundle was first generated.
|
||||
* - Otherwise we generate a fresh bundle (which is now persisted to
|
||||
* disk by [KeyPackageRotationManager.generateKeyPackage]) and
|
||||
* publish the corresponding event.
|
||||
*
|
||||
* Best-effort: failures are logged but never propagated. We don't want
|
||||
* a flaky relay or missing outbox config at startup to crash account
|
||||
* initialization.
|
||||
*/
|
||||
internal suspend fun ensureMarmotKeyPackagePublished() {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
try {
|
||||
val hasBundle = manager.hasActiveKeyPackages()
|
||||
Log.d("MarmotDbg") {
|
||||
"ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${account.signer.pubKey.take(8)}…"
|
||||
}
|
||||
if (hasBundle) {
|
||||
return
|
||||
}
|
||||
Log.d("MarmotDbg") {
|
||||
"ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now"
|
||||
}
|
||||
publishMarmotKeyPackage()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a KeyPackage has been published in this session.
|
||||
* The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's
|
||||
* persisted snapshot, so there is no fixed address to query in the cache.
|
||||
*/
|
||||
suspend fun hasPublishedKeyPackage(): Boolean {
|
||||
val manager = account.marmotManager ?: return false
|
||||
return manager.hasActiveKeyPackages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Marmot MLS group.
|
||||
*/
|
||||
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
manager.createGroup(nostrGroupId)
|
||||
// Creator owns the group — mark it as "known" immediately so it
|
||||
// doesn't appear under "New Requests" before the first message.
|
||||
account.marmotGroupList.markAsKnown(nostrGroupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a Marmot MLS group.
|
||||
* Publishes the SelfRemove proposal and removes local state.
|
||||
*
|
||||
* MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions
|
||||
* commit dropping themselves from `admin_pubkeys` before issuing a
|
||||
* SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws
|
||||
* `IllegalStateException("Admin must self-demote via GroupContextExtensions
|
||||
* before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and
|
||||
* SelfRemove proposal both go to the same group relays, demote first so
|
||||
* peers apply it before they see the SelfRemove.
|
||||
*/
|
||||
suspend fun leaveMarmotGroup(
|
||||
nostrGroupId: HexKey,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val metadata = manager.groupMetadata(nostrGroupId)
|
||||
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
|
||||
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
|
||||
// MIP-03 also rejects any GCE commit that leaves the group with zero
|
||||
// admins. If we're the only one, promote an arbitrary non-self
|
||||
// member to admin before stepping down.
|
||||
if (remaining.isEmpty()) {
|
||||
val heir =
|
||||
manager
|
||||
.memberPubkeys(nostrGroupId)
|
||||
.map { it.pubkey }
|
||||
.firstOrNull { it != account.signer.pubKey }
|
||||
if (heir != null) remaining.add(heir)
|
||||
}
|
||||
if (remaining.isNotEmpty()) {
|
||||
val demoted = metadata.copy(adminPubkeys = remaining)
|
||||
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
|
||||
account.client.publish(demoteCommit.signedEvent, groupRelays)
|
||||
}
|
||||
}
|
||||
|
||||
val outbound = manager.leaveGroup(nostrGroupId)
|
||||
// manager.leaveGroup already wiped MLS state, relay subscriptions and
|
||||
// the persisted message log. Drop the in-memory chatroom too — that
|
||||
// releases the strong refs to the decrypted inner notes so LocalCache
|
||||
// (which holds them weakly) can GC them, and the Notification feed
|
||||
// (which iterates account.marmotGroupList.rooms) stops surfacing the group.
|
||||
account.marmotGroupList.removeGroup(nostrGroupId)
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* User-initiated "nuclear" reset for the Marmot subsystem.
|
||||
*
|
||||
* Wipes every MLS group, every retained epoch secret, every persisted
|
||||
* KeyPackage bundle, every relay subscription and every in-memory
|
||||
* chatroom associated with this account. Does NOT broadcast any
|
||||
* SelfRemove/leave commits to peers — if the user is in this flow at
|
||||
* all, local state may already be unusable and a graceful leave is
|
||||
* probably not possible. Peers will see the user as unresponsive until
|
||||
* their next commit evicts the stale leaf.
|
||||
*
|
||||
* A fresh KeyPackage will be republished lazily on the next
|
||||
* `ensureMarmotKeyPackagePublished` cycle, so the account remains
|
||||
* reachable for future group invites.
|
||||
*/
|
||||
suspend fun resetMarmotState() {
|
||||
Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${account.signer.pubKey.take(8)}…" }
|
||||
account.marmotManager?.resetAllState()
|
||||
for (groupId in account.marmotGroupList.allGroupIds()) {
|
||||
account.marmotGroupList.removeGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a Marmot MLS group.
|
||||
* Publishes the commit GroupEvent to group relays.
|
||||
*/
|
||||
suspend fun removeMarmotGroupMember(
|
||||
nostrGroupId: HexKey,
|
||||
targetLeafIndex: Int,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
Log.d("MarmotDbg") {
|
||||
"removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " +
|
||||
"groupRelays=${groupRelays.size}"
|
||||
}
|
||||
val manager =
|
||||
account.marmotManager ?: run {
|
||||
Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" }
|
||||
return
|
||||
}
|
||||
if (!account.isWriteable()) {
|
||||
Log.w("MarmotDbg") { "removeMarmotGroupMember: account is not writeable — no-op" }
|
||||
return
|
||||
}
|
||||
|
||||
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
|
||||
Log.d("MarmotDbg") {
|
||||
"removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…"
|
||||
}
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
manager.syncMetadataTo(nostrGroupId, chatroom)
|
||||
Log.d("MarmotDbg") {
|
||||
"removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}… " +
|
||||
"to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
|
||||
}
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Marmot MLS group's metadata (name, description, etc.).
|
||||
* Publishes the commit GroupEvent to group relays.
|
||||
*/
|
||||
suspend fun updateMarmotGroupMetadata(
|
||||
nostrGroupId: HexKey,
|
||||
metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
|
||||
// The MLS commit has already been applied locally — surface the new
|
||||
// metadata in the chatroom now so the UI reflects it without waiting
|
||||
// for the relay round-trip.
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
manager.syncMetadataTo(nostrGroupId, chatroom)
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant admin privileges to [targetPubKey] in a Marmot MLS group by
|
||||
* appending them to `admin_pubkeys` via a GroupContextExtensions commit.
|
||||
*
|
||||
* No-op if the group has no prior metadata (shouldn't happen outside the
|
||||
* first bootstrap commit) or the target is already an admin. Callers
|
||||
* must be an admin themselves — the MLS engine enforces this via the
|
||||
* MIP-03 authorization gate in `enforceAuthorizedProposalSet`.
|
||||
*/
|
||||
suspend fun grantMarmotGroupAdmin(
|
||||
nostrGroupId: HexKey,
|
||||
targetPubKey: HexKey,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val metadata = manager.groupMetadata(nostrGroupId) ?: return
|
||||
if (metadata.adminPubkeys.contains(targetPubKey)) return
|
||||
|
||||
val outboxRelayStrings =
|
||||
account.outboxRelays.flow.value
|
||||
.map { it.url }
|
||||
val updated =
|
||||
metadata
|
||||
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
|
||||
.withMergedRelays(outboxRelayStrings)
|
||||
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke admin privileges from [targetPubKey]. Rejects any change that
|
||||
* would leave the group with zero admins — MIP-03's admin-depletion guard
|
||||
* in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise
|
||||
* throw at commit time.
|
||||
*/
|
||||
suspend fun revokeMarmotGroupAdmin(
|
||||
nostrGroupId: HexKey,
|
||||
targetPubKey: HexKey,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val metadata = manager.groupMetadata(nostrGroupId) ?: return
|
||||
if (!metadata.adminPubkeys.contains(targetPubKey)) return
|
||||
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
|
||||
check(remaining.isNotEmpty()) {
|
||||
"Cannot revoke the last admin from a Marmot group (MIP-03)"
|
||||
}
|
||||
|
||||
val outboxRelayStrings =
|
||||
account.outboxRelays.flow.value
|
||||
.map { it.url }
|
||||
val updated =
|
||||
metadata
|
||||
.copy(adminPubkeys = remaining)
|
||||
.withMergedRelays(outboxRelayStrings)
|
||||
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
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.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
|
||||
* join/leave/create/delete/archive groups, threads, invites, pins, member and
|
||||
* role management, metadata edits, plus the Buzz dialect's DMs, jobs,
|
||||
* workflows, and typing signals. Event building lives in quartz builders;
|
||||
* this class wires them to the account's signer and the group's host relay.
|
||||
*/
|
||||
class AccountRelayGroupActions(
|
||||
private val account: Account,
|
||||
) {
|
||||
// All group commands are published ONLY to the group's host relay, where
|
||||
// relay29 authorizes them. The relay is the source of truth; the kind-10009
|
||||
// list is our own cross-device bookkeeping of what we joined.
|
||||
|
||||
/** Send a kind 9021 join request to the group's host relay and remember it. */
|
||||
suspend fun joinRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
code: String? = null,
|
||||
) {
|
||||
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.follow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
|
||||
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
|
||||
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
|
||||
*/
|
||||
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
|
||||
if (!account.isWriteable()) return
|
||||
val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
|
||||
account.client.publish(signed, setOf(channel.groupId.relayUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010
|
||||
* command. [participants] are the OTHER 1-8 people — the relay adds me, derives the
|
||||
* canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent]
|
||||
* (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry].
|
||||
* We never assign the channel id ourselves, so callers discover the materialized DM
|
||||
* by watching that registry rather than from this call's return.
|
||||
*/
|
||||
suspend fun openBuzzDm(
|
||||
relay: NormalizedRelayUrl,
|
||||
participants: List<HexKey>,
|
||||
): String? {
|
||||
val signed = account.signer.sign(DmOpenEvent.build(participants))
|
||||
// The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` —
|
||||
// the authoritative, relay-assigned channel UUID (the deployed relay does not emit a
|
||||
// queryable kind-41001). Read it straight from the ack so the caller can open the chat.
|
||||
var results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||
var channelId = buzzDmChannelIdFromAck(results)
|
||||
|
||||
// NIP-42 write race: on a cold connection the relay rejects the first publish with
|
||||
// `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm
|
||||
// the connection with a pendingOnAuthRequired read so the auth coordinator completes the
|
||||
// handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix.
|
||||
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
|
||||
timeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||
channelId = buzzDmChannelIdFromAck(results)
|
||||
}
|
||||
return channelId
|
||||
}
|
||||
|
||||
/** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */
|
||||
private fun buzzDmChannelIdFromAck(results: Map<NormalizedRelayUrl, PublishResult>): String? =
|
||||
results.values
|
||||
.firstOrNull { it.accepted }
|
||||
?.message
|
||||
?.substringAfter("\"channel_id\":\"", "")
|
||||
?.substringBefore('"')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
|
||||
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
|
||||
val template = DmHideEvent.build(channel.groupId.id)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */
|
||||
suspend fun addBuzzDmMember(
|
||||
channel: RelayGroupChannel,
|
||||
member: HexKey,
|
||||
) {
|
||||
val template = DmAddMemberEvent.build(channel.groupId.id, member)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared
|
||||
* feature-request the workspace bot can pick up. Untargeted: any agent watching the
|
||||
* channel may accept it. Returns the new job id (the request event id), or null when the
|
||||
* account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator].
|
||||
*/
|
||||
suspend fun fileBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
request: String,
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val signed = account.signer.sign(JobRequestEvent.build(request, channelId, null))
|
||||
// Reflect it locally so the board updates immediately (publish only sends to relays).
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */
|
||||
suspend fun cancelBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
jobId: HexKey,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val signed = account.signer.sign(JobCancelEvent.build(jobId, "", channelId))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on
|
||||
* [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the
|
||||
* approval token), returned here. A run pauses on a human-approval gate before anything ships —
|
||||
* see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator].
|
||||
*/
|
||||
suspend fun triggerBuzzWorkflow(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
workflowId: String,
|
||||
task: String,
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId))
|
||||
val signed = account.signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) })
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an
|
||||
* addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a
|
||||
* human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses
|
||||
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
|
||||
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
|
||||
*/
|
||||
suspend fun publishBuzzWorkflowDef(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
name: String,
|
||||
yaml: String,
|
||||
): String? {
|
||||
if (!account.isWriteable()) return null
|
||||
val workflowId = RandomInstance.randomChars(16)
|
||||
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return workflowId
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which
|
||||
* doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work.
|
||||
* Publishing to the single group [relay]; the runner discovers the decision by author.
|
||||
*/
|
||||
suspend fun approveBuzzWorkflowRun(
|
||||
relay: NormalizedRelayUrl,
|
||||
runId: HexKey,
|
||||
note: String = "",
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val signed = account.signer.sign(ApprovalGrantEvent.build(runId, note))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */
|
||||
suspend fun denyBuzzWorkflowRun(
|
||||
relay: NormalizedRelayUrl,
|
||||
runId: HexKey,
|
||||
note: String = "",
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val signed = account.signer.sign(ApprovalDenyEvent.build(runId, note))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging
|
||||
* the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and
|
||||
* `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority.
|
||||
*/
|
||||
suspend fun upvoteBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
jobId: HexKey,
|
||||
jobAuthor: HexKey?,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val template =
|
||||
eventTemplate<ReactionEvent>(ReactionEvent.KIND, ReactionEvent.LIKE) {
|
||||
addUnique(ETag.assemble(jobId, null, null))
|
||||
jobAuthor?.let { addUnique(PTag.assemble(it, null)) }
|
||||
addUnique(arrayOf("k", JobRequestEvent.KIND.toString()))
|
||||
addUnique(GroupIdTag.assemble(channelId))
|
||||
}
|
||||
val signed = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
}
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.unfollow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay
|
||||
* enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than
|
||||
* just removing me; the relay drops the group and its messages. Also drops it from our own list
|
||||
* so it disappears from Messages immediately instead of lingering as a now-dead id.
|
||||
*/
|
||||
suspend fun deleteRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = DeleteGroupEvent.build(channel.groupId.id)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.unfollow(channel)
|
||||
// Remember the deletion so the channel leaves the community's browse list immediately and
|
||||
// stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a
|
||||
// stale re-announced 44100 on a Buzz relay) would otherwise keep it visible.
|
||||
RelayGroupDeletions.markDeleted(channel.groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
|
||||
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
|
||||
* the new group's id.
|
||||
*/
|
||||
suspend fun createRelayGroup(
|
||||
relay: NormalizedRelayUrl,
|
||||
groupId: String,
|
||||
name: String,
|
||||
about: String? = null,
|
||||
picture: String? = null,
|
||||
isPrivate: Boolean = false,
|
||||
isClosed: Boolean = false,
|
||||
isHidden: Boolean = false,
|
||||
isRestricted: Boolean = false,
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
parent: String? = null,
|
||||
channelType: String? = null,
|
||||
): GroupId {
|
||||
// The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes
|
||||
// its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without
|
||||
// a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay
|
||||
// publish two events and produce nothing at all.
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(
|
||||
CreateGroupEvent.build(
|
||||
groupId = groupId,
|
||||
name = name,
|
||||
about = about,
|
||||
visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN,
|
||||
channelType = channelType,
|
||||
),
|
||||
) { listOf(relay) }
|
||||
|
||||
val edit =
|
||||
EditMetadataEvent.build(
|
||||
groupId,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
parent = parent,
|
||||
)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
|
||||
|
||||
val id = GroupId(groupId, relay)
|
||||
account.follow(LocalCache.getOrCreateRelayGroupChannel(id))
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are
|
||||
* presence-only — public/open/visible/unrestricted are simply the ABSENCE of their
|
||||
* restrictive counterpart — so only the enabled restrictive flags are added.
|
||||
*/
|
||||
private fun relayGroupStatus(
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
): Set<GroupMetadataEvent.GroupStatus> =
|
||||
buildSet {
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN)
|
||||
if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
|
||||
}
|
||||
|
||||
/** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */
|
||||
suspend fun postRelayGroupThread(
|
||||
channel: RelayGroupChannel,
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
val template =
|
||||
ThreadEvent.build(body, title) {
|
||||
hTag(channel.groupId.id)
|
||||
previous(channel.previousEventRefs(account.pubKey))
|
||||
}
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Mint a kind 9009 invite code for the group (admin/moderator only). */
|
||||
suspend fun createRelayGroupInvite(
|
||||
channel: RelayGroupChannel,
|
||||
code: String,
|
||||
) {
|
||||
val template = CreateInviteEvent.build(channel.groupId.id, code)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the group's pinned-message list with a kind 9010 update-pin-list event
|
||||
* (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and
|
||||
* republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
|
||||
*/
|
||||
suspend fun updateRelayGroupPins(
|
||||
channel: RelayGroupChannel,
|
||||
pinnedEventIds: List<HexKey>,
|
||||
) {
|
||||
val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Pin [eventId] by appending it to the current list (no-op if already pinned). */
|
||||
suspend fun pinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
eventId: HexKey,
|
||||
) {
|
||||
if (channel.isPinned(eventId)) return
|
||||
updateRelayGroupPins(channel, channel.pinnedEventIds + eventId)
|
||||
}
|
||||
|
||||
/** Unpin [eventId] by removing it from the current list (no-op if not pinned). */
|
||||
suspend fun unpinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
eventId: HexKey,
|
||||
) {
|
||||
if (!channel.isPinned(eventId)) return
|
||||
updateRelayGroupPins(channel, channel.pinnedEventIds - eventId)
|
||||
}
|
||||
|
||||
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
|
||||
suspend fun removeRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
) {
|
||||
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
|
||||
* event (moderator only). Pass an empty [roles] list for a plain member.
|
||||
*/
|
||||
suspend fun putRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
roles: List<String>,
|
||||
) {
|
||||
// Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its
|
||||
// own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails
|
||||
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
|
||||
val buzzRole =
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
when {
|
||||
roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN
|
||||
else -> BUZZ_ROLE_MEMBER
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the
|
||||
* relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the
|
||||
* sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to
|
||||
* [relay] with no channel scope.
|
||||
*/
|
||||
suspend fun addCommunityMember(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey,
|
||||
role: String? = null,
|
||||
) {
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) }
|
||||
}
|
||||
|
||||
/** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */
|
||||
suspend fun removeCommunityMember(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey,
|
||||
) {
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
|
||||
*
|
||||
* NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy
|
||||
* links: a 9002 with no `parent` tag re-roots the group, and one that drops any
|
||||
* existing `child` is rejected by the relay. So unless the caller is explicitly
|
||||
* re-parenting, we re-carry the group's current [parent] and full [children] list
|
||||
* from its latest known metadata to keep the tree intact across a plain name/flag
|
||||
* edit. Pass an explicit value to change them.
|
||||
*/
|
||||
suspend fun editRelayGroupMetadata(
|
||||
channel: RelayGroupChannel,
|
||||
name: String?,
|
||||
about: String?,
|
||||
picture: String?,
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
parent: String? = channel.parentGroupId(),
|
||||
children: List<String> = channel.childGroupIds(),
|
||||
) {
|
||||
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
|
||||
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
|
||||
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
|
||||
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
|
||||
val template =
|
||||
EditMetadataEvent.build(
|
||||
channel.groupId.id,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
parent = parent,
|
||||
children = children,
|
||||
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
|
||||
)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
|
||||
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
|
||||
* history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
|
||||
*/
|
||||
suspend fun archiveRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
archived: Boolean,
|
||||
) {
|
||||
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
|
||||
|
||||
/**
|
||||
* Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47
|
||||
* NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC
|
||||
* onchain zaps/sends. Event building lives in the commons ZapActions/
|
||||
* Bolt12ZapActions; this class wires wallet selection, signing, and relay
|
||||
* routing to the account.
|
||||
*/
|
||||
class AccountZapActions(
|
||||
private val account: Account,
|
||||
) {
|
||||
suspend fun createZapRequestFor(
|
||||
event: Event,
|
||||
pollOption: Int?,
|
||||
message: String = "",
|
||||
zapType: LnZapEvent.ZapType,
|
||||
toUser: User?,
|
||||
additionalRelays: Set<NormalizedRelayUrl>? = null,
|
||||
amountMillisats: Long? = null,
|
||||
lnurl: String? = null,
|
||||
) = LnZapRequestEvent.create(
|
||||
zappedEvent = event,
|
||||
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
|
||||
signer = account.signer,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
toUserPubHex = toUser?.pubkeyHex,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
|
||||
suspend fun calculateIfNoteWasZappedByAccount(
|
||||
zappedNote: Note?,
|
||||
afterTimeInSeconds: Long,
|
||||
): Boolean = zappedNote?.isZappedBy(account.userProfile(), afterTimeInSeconds, account) == true
|
||||
|
||||
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(account.nip47SignerState)
|
||||
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
suspend fun sendNwcRequestToWallet(
|
||||
walletUri: Nip47WalletConnect.Nip47URINorm,
|
||||
request: Request,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): HexKey {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
return event.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
|
||||
* the given request id. 0 if the request is unknown or already resolved.
|
||||
*/
|
||||
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
|
||||
|
||||
/**
|
||||
* Removes a pending NIP-47 request from the tracker. Call this when the
|
||||
* UI gives up waiting (timeout) so the entry doesn't stick around.
|
||||
*/
|
||||
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
|
||||
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a
|
||||
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
|
||||
* info event (its capability advertisement), which [NwcSignerState] already refreshes
|
||||
* on wallet change. A missing/unfetched info event reads as false, so the zap path
|
||||
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
|
||||
*/
|
||||
fun defaultWalletSupportsBolt12Pay(): Boolean {
|
||||
val uri = account.nip47SignerState.defaultWalletUri.value ?: return false
|
||||
return account.nip47SignerState.infoCache
|
||||
?.current(uri)
|
||||
?.supportsMethod(NwcMethod.PAY) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
|
||||
*
|
||||
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
|
||||
* intent-bound `payer_note`, then — only if the wallet returns a payer proof that
|
||||
* validates — builds, self-consumes, and publishes the kind 9736 zap. Validation
|
||||
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
|
||||
* fails the binding check, so no invalid receipt is ever published (the payment
|
||||
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
|
||||
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
|
||||
* external-wallet or LNURL fallback because only NWC returns the proof.
|
||||
*/
|
||||
suspend fun sendBolt12Zap(
|
||||
zappedEvent: Event?,
|
||||
recipientPubKey: HexKey,
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
|
||||
onError: (Int, String?) -> Unit,
|
||||
onProcessed: () -> Unit,
|
||||
) {
|
||||
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
|
||||
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
|
||||
if (zapType == LnZapEvent.ZapType.NONZAP) {
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
account.scope.launch {
|
||||
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
onProcessed()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
|
||||
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
|
||||
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
|
||||
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer
|
||||
|
||||
val intent =
|
||||
if (zappedEvent == null) {
|
||||
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
|
||||
} else {
|
||||
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
|
||||
}
|
||||
|
||||
val payerNote = Bolt12ZapBuilder.payerNote(intent)
|
||||
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
|
||||
account.scope.launch {
|
||||
// try/finally so a failure while assembling/publishing the receipt (e.g. a
|
||||
// remote signer error) still steps progress and surfaces an error, instead
|
||||
// of vanishing as an uncaught coroutine exception. The payment already
|
||||
// settled at this point, so such a failure means "paid, no receipt".
|
||||
try {
|
||||
when (response) {
|
||||
is PaySuccessResponse -> {
|
||||
val proof = response.result?.payer_proof
|
||||
if (proof.isNullOrBlank()) {
|
||||
onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
} else {
|
||||
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
|
||||
if (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
|
||||
account.cache.justConsumeMyOwnEvent(zap)
|
||||
account.client.publish(zap, account.broadcaster.computeRelayListToBroadcast(zap))
|
||||
} else {
|
||||
onError(R.string.bolt12_zap_invalid_receipt, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
|
||||
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
|
||||
onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
} finally {
|
||||
onProcessed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createZapRequestFor(
|
||||
user: User,
|
||||
message: String = "",
|
||||
zapType: LnZapEvent.ZapType,
|
||||
amountMillisats: Long? = null,
|
||||
lnurl: String? = null,
|
||||
): LnZapRequestEvent {
|
||||
val zapRequest =
|
||||
LnZapRequestEvent.create(
|
||||
userHex = user.pubkeyHex,
|
||||
relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
|
||||
signer = account.signer,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
|
||||
account.cache.justConsumeMyOwnEvent(zapRequest)
|
||||
return zapRequest
|
||||
}
|
||||
|
||||
private fun onchainBackendNotConfigured() =
|
||||
OnchainZapSendResult.Failure(
|
||||
OnchainZapSendStage.LOADING_UTXOS,
|
||||
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
|
||||
ONCHAIN_BACKEND_NOT_CONFIGURED,
|
||||
)
|
||||
|
||||
/**
|
||||
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
|
||||
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
|
||||
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
|
||||
* leave it null for a profile zap.
|
||||
*/
|
||||
suspend fun sendOnchainZap(
|
||||
recipientPubKey: HexKey,
|
||||
amountSats: Long,
|
||||
feeRateSatPerVByte: Double,
|
||||
comment: String = "",
|
||||
zappedEvent: EventHintBundle<out Event>? = null,
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
account.cache.onchainBackend
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.send(
|
||||
backend = backend,
|
||||
signer = account.signer,
|
||||
senderPubKey = account.signer.pubKey,
|
||||
recipientPubKey = recipientPubKey,
|
||||
amountSats = amountSats,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
comment = comment,
|
||||
zappedEvent = zappedEvent,
|
||||
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
|
||||
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
|
||||
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
|
||||
*/
|
||||
suspend fun sendOnchainToAddress(
|
||||
recipientAddress: String,
|
||||
amountSats: Long,
|
||||
feeRateSatPerVByte: Double,
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
account.cache.onchainBackend
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.sendToAddress(
|
||||
backend = backend,
|
||||
signer = account.signer,
|
||||
senderPubKey = account.signer.pubKey,
|
||||
recipientAddress = recipientAddress,
|
||||
amountSats = amountSats,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
|
||||
* each recipient their precomputed share, plus one kind:8333 receipt per
|
||||
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
|
||||
*/
|
||||
suspend fun sendOnchainZapWithSplits(
|
||||
recipients: List<OnchainZapShare>,
|
||||
feeRateSatPerVByte: Double,
|
||||
comment: String = "",
|
||||
zappedEvent: EventHintBundle<out Event>? = null,
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
account.cache.onchainBackend
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.sendSplit(
|
||||
backend = backend,
|
||||
signer = account.signer,
|
||||
senderPubKey = account.signer.pubKey,
|
||||
recipients = recipients,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
comment = comment,
|
||||
zappedEvent = zappedEvent,
|
||||
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Memory-reclaim policy over the [LocalCache] stores: trims the soft caches,
|
||||
* prunes hidden/old/expired/superseded events, and owns the shared
|
||||
* [unlinkAndRemove] removal primitive that [LocalCache.deleteNote] also relies on.
|
||||
*
|
||||
* Pure policy — it holds no state of its own beyond the cache reference, so every
|
||||
* function can be exercised against a populated cache in tests. Driven by
|
||||
* `MemoryTrimmingService`.
|
||||
*/
|
||||
class CachePruner(
|
||||
private val cache: LocalCache,
|
||||
) {
|
||||
fun cleanMemory() {
|
||||
Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" }
|
||||
cache.notes.cleanUp()
|
||||
Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${cache.notes.size()}" }
|
||||
|
||||
Log.d("LargeCache") { "Addressables cleanup started. Current size: ${cache.addressables.size()}" }
|
||||
cache.addressables.cleanUp()
|
||||
Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${cache.addressables.size()}" }
|
||||
|
||||
Log.d("LargeCache") { "Users cleanup started. Current size: ${cache.users.size()}" }
|
||||
cache.users.cleanUp()
|
||||
Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${cache.users.size()}" }
|
||||
}
|
||||
|
||||
fun cleanObservers() {
|
||||
cache.notes.forEach { _, it -> it.clearFlow() }
|
||||
cache.addressables.forEach { _, it -> it.clearFlow() }
|
||||
}
|
||||
|
||||
private fun pruneHiddenMessagesChannel(
|
||||
channel: Channel,
|
||||
account: Account,
|
||||
) {
|
||||
val toBeRemoved = channel.pruneHiddenMessages(account)
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account) {
|
||||
cache.ephemeralChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.geohashChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.liveChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.publicChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.relayGroupChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
}
|
||||
|
||||
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
|
||||
// `NestsFeedFilter` so a presence still inside any feed's window
|
||||
// can never be pruned.
|
||||
private val presencePruneAgeSeconds = 20L * 60L
|
||||
|
||||
private fun pruneOldMessagesChannel(channel: Channel) {
|
||||
val toBeRemoved = channel.pruneOldMessages()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
// Audio-room presence is keyed separately from `notes` and
|
||||
// never gets reaped by the top-N rule. Drop entries older
|
||||
// than 2× the 10-min freshness window so the index doesn't
|
||||
// grow unbounded with every author who ever heartbeat here.
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
channel.pruneStalePresence(TimeUtils.now() - presencePruneAgeSeconds)
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldMessages() {
|
||||
checkNotInMainThread()
|
||||
|
||||
cache.ephemeralChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.geohashChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.liveChatChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.publicChatChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.relayGroupChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.chatroomList.forEach { userHex, room ->
|
||||
// History floors are pinned per scope on first advance; null means that window never paged
|
||||
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
|
||||
// bands strictly BELOW a floor are this window's responsibility — a pruned message newer than
|
||||
// the floor is the always-on live tail's concern, and rewinding history for it would needlessly
|
||||
// re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor
|
||||
// filter when accumulating below.
|
||||
val giftWrapFloor = room.giftWrapHistory.floor
|
||||
val accountNip04Floor = room.nip04History.floor
|
||||
|
||||
room.rooms.map { key, chatroom ->
|
||||
val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
// Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor.
|
||||
// Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's
|
||||
// own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor.
|
||||
val giftWrapPruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
val roomNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
// chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a
|
||||
// kind:4 message, so rooms that never paged conversation history pay nothing.
|
||||
val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null
|
||||
|
||||
toBeRemoved.forEach { note ->
|
||||
when (val ev = note.event) {
|
||||
is BaseDMGroupEvent ->
|
||||
if (giftWrapFloor != null) {
|
||||
val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt
|
||||
if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) }
|
||||
}
|
||||
is PrivateDmEvent -> {
|
||||
val until = ev.createdAt
|
||||
if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) }
|
||||
if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) }
|
||||
}
|
||||
}
|
||||
|
||||
childrenToBeRemoved.addAll(removeIfWrap(note))
|
||||
unlinkAndRemove(note)
|
||||
|
||||
childrenToBeRemoved.addAll(note.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
// Realign the windows so a relay that already paged past (or `done` below) the dropped band
|
||||
// re-requests it on the next demand-advance instead of skipping the hole.
|
||||
if (giftWrapPruned.isNotEmpty()) {
|
||||
room.giftWrapHistory.rewindTo(giftWrapPruned)
|
||||
Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" }
|
||||
}
|
||||
if (accountNip04Pruned.isNotEmpty()) {
|
||||
room.nip04History.rewindTo(accountNip04Pruned)
|
||||
Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" }
|
||||
}
|
||||
if (roomNip04Pruned.isNotEmpty()) {
|
||||
chatroom.nip04History.rewindTo(roomNip04Pruned)
|
||||
Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" }
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeIfWrap(note: Note): List<Note> {
|
||||
val host = note.rumorHost ?: return emptyList()
|
||||
|
||||
val children = mutableListOf<Note>()
|
||||
cache.getNoteIfExists(host.id)?.let { hostNote ->
|
||||
(hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId ->
|
||||
cache.getNoteIfExists(sealId)?.let { sealNote ->
|
||||
unlinkAndRemove(sealNote)
|
||||
children.addAll(sealNote.clearChildLinks())
|
||||
}
|
||||
}
|
||||
unlinkAndRemove(hostNote)
|
||||
children.addAll(hostNote.clearChildLinks())
|
||||
}
|
||||
note.rumorHost = null
|
||||
return children
|
||||
}
|
||||
|
||||
fun prunePastVersionsOfReplaceables() {
|
||||
val toBeRemoved =
|
||||
cache.notes.filter { _, note ->
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
noteEvent.createdAt <
|
||||
(
|
||||
cache.addressables
|
||||
.get(noteEvent.address())
|
||||
?.event
|
||||
?.createdAt ?: 0
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> cache.addressables.get(tag) }
|
||||
if (newerVersion != null) {
|
||||
it.moveAllReferencesTo(newerVersion)
|
||||
}
|
||||
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved =
|
||||
cache.notes.filter { _, note ->
|
||||
(
|
||||
(note.event is TextNoteEvent && !note.isNewThread()) ||
|
||||
note.event is ReactionEvent ||
|
||||
note.event is LnZapEvent ||
|
||||
note.event is LnZapRequestEvent ||
|
||||
note.event is ReportEvent ||
|
||||
note.event is GenericRepostEvent
|
||||
) &&
|
||||
note.replyTo?.any { it.flowSet?.isInUse() == true } != true &&
|
||||
note.flowSet?.isInUse() != true &&
|
||||
// don't delete if observing.
|
||||
note.author?.pubkeyHex !in
|
||||
accounts &&
|
||||
// don't delete if it is the logged in account
|
||||
note.event?.isTaggedUsers(accounts) !=
|
||||
true // don't delete if it's a notification to the logged in user
|
||||
}
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlinks [note] from everything in the cache that references it, then drops it
|
||||
* from the notes map and notifies observers. This is the shared "unlink from
|
||||
* above" half of removal, used by both the prune callers and [LocalCache.deleteNote].
|
||||
*
|
||||
* It detaches the note from:
|
||||
* - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps);
|
||||
* because event-level reports and torrent comments both carry the target in
|
||||
* `replyTo`, [Note.removeNote] cleans those up here too;
|
||||
* - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote`
|
||||
* always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders
|
||||
* resolve so a note can never linger in a channel after leaving the cache);
|
||||
* - the per-target indexes `replyTo` does NOT reach: user-level reports and
|
||||
* reported addresses, contact cards, statuses, and poll responses.
|
||||
*
|
||||
* It deliberately does NOT touch the note's own children: prune callers collect
|
||||
* them via [Note.clearChildLinks] and remove the subtree, while [LocalCache.deleteNote]
|
||||
* keeps them and severs only their back-reference. Every per-target removal is
|
||||
* idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an
|
||||
* event-level report reachable both ways) is harmless. Addressable notes are
|
||||
* dropped from the addressables map by the caller; this only removes from notes.
|
||||
*/
|
||||
fun unlinkAndRemove(note: Note) {
|
||||
note.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeNote(note)
|
||||
}
|
||||
|
||||
note.inGatherers?.forEach { it.removeNote(note) }
|
||||
|
||||
cache.getAnyChannel(note)?.removeNote(note)
|
||||
|
||||
val noteEvent = note.event
|
||||
|
||||
// Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so
|
||||
// detach this note from every quoted note's boosts here.
|
||||
noteEvent?.taggedQuoteIds()?.forEach { quotedId ->
|
||||
cache.getNoteIfExists(quotedId)?.removeBoost(note)
|
||||
}
|
||||
|
||||
// Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo`
|
||||
// back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag
|
||||
// and drop it there, or a deleted edit would keep overlaying its message.
|
||||
editedTargetIdOf(noteEvent)?.let { cache.getNoteIfExists(it)?.removeEdit(note) }
|
||||
|
||||
// OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with
|
||||
// no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there.
|
||||
if (noteEvent is OtsEvent) {
|
||||
noteEvent.digestEventId()?.let { cache.getNoteIfExists(it)?.removeTimestamp(note) }
|
||||
}
|
||||
|
||||
if (noteEvent is ReportEvent) {
|
||||
noteEvent.reportedAuthor().forEach {
|
||||
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
|
||||
reports.removeReport(note)
|
||||
reports.removeReportNamingUser(note)
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent.reportedPost().forEach {
|
||||
cache.getNoteIfExists(it.eventId)?.removeReport(note)
|
||||
}
|
||||
|
||||
noteEvent.reportedAddresses().forEach {
|
||||
cache.getAddressableNoteIfExists(it.address)?.removeReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
if (note is AddressableNote && noteEvent is ContactCardEvent) {
|
||||
cache.getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
|
||||
}
|
||||
|
||||
if (note is AddressableNote && noteEvent is StatusEvent) {
|
||||
note.author?.statusStateOrNull()?.removeStatus(note)
|
||||
}
|
||||
|
||||
if (noteEvent is PollResponseEvent) {
|
||||
noteEvent.poll()?.eventId?.let {
|
||||
cache.getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note)
|
||||
}
|
||||
}
|
||||
|
||||
note.clearFlow()
|
||||
|
||||
cache.notes.remove(note.idHex)
|
||||
|
||||
cache.refreshDeletedNoteObservers(note)
|
||||
}
|
||||
|
||||
/** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */
|
||||
private fun editedTargetIdOf(event: Event?): HexKey? =
|
||||
when (event) {
|
||||
is TextNoteModificationEvent -> event.editedNote()?.eventId
|
||||
is ConcordChatEditEvent -> event.editedMessageId()
|
||||
is StreamMessageEditEvent -> event.editedMessage()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
|
||||
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
|
||||
}
|
||||
|
||||
fun pruneExpiredEvents() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val now = TimeUtils.now()
|
||||
val versionsToBeRemoved = cache.notes.filter { _, it -> it.event?.isExpirationBefore(now) == true }
|
||||
val addressesToBeRemoved = cache.addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true }
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
versionsToBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
addressesToBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
|
||||
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneHiddenEvents(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
val toBeRemoved =
|
||||
account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex ->
|
||||
(cache.notes.filter { _, it -> it.event?.pubKey == userHex } + cache.addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet()
|
||||
}
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* Prefix/content search over the [LocalCache] stores: users, notes, and the
|
||||
* public-chat / ephemeral / live-activity channel maps. Pure read-side policy —
|
||||
* no state beyond the cache reference — so ranking and filtering rules can be
|
||||
* tested against a populated cache.
|
||||
*/
|
||||
class CacheSearch(
|
||||
private val cache: LocalCache,
|
||||
) {
|
||||
fun findUsersStartingWith(
|
||||
username: String,
|
||||
forAccount: Account?,
|
||||
): List<User> {
|
||||
if (username.isBlank()) return emptyList()
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = decodePublicKeyAsHexOrNull(username)
|
||||
|
||||
if (key != null) {
|
||||
val user = cache.getUserIfExists(key)
|
||||
if (user != null) {
|
||||
return listOfNotNull(user)
|
||||
}
|
||||
}
|
||||
|
||||
val dualCase =
|
||||
listOf(
|
||||
DualCase(username.lowercase(), username.uppercase()),
|
||||
)
|
||||
|
||||
val finds =
|
||||
cache.users.filter { _, user: User ->
|
||||
val metadata = user.metadataOrNull()
|
||||
if (metadata == null) {
|
||||
user.pubkeyHex.startsWith(username, true) ||
|
||||
user.pubkeyNpub().startsWith(username, true)
|
||||
} else {
|
||||
(
|
||||
metadata.anyNameOrAddressContains(dualCase) ||
|
||||
user.pubkeyHex.startsWith(username, true) ||
|
||||
user.pubkeyNpub().startsWith(username, true)
|
||||
) &&
|
||||
(forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase)))
|
||||
}
|
||||
}
|
||||
|
||||
val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true }
|
||||
val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true }
|
||||
val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true }
|
||||
val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() }
|
||||
|
||||
return finds.sortedWith(
|
||||
compareBy(
|
||||
{ findsFollowing[it] == false },
|
||||
{ anyNameStartsWith[it] == false },
|
||||
{ anyAddressStartsWith[it] == false },
|
||||
{ displayNames[it] },
|
||||
{ it.pubkeyHex },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Will return true if supplied note is one of events to be excluded from
|
||||
* search results.
|
||||
*/
|
||||
private fun excludeNoteEventFromSearchResults(note: Note): Boolean =
|
||||
(
|
||||
note.event is GenericRepostEvent ||
|
||||
note.event is RepostEvent ||
|
||||
note.event is CommunityPostApprovalEvent ||
|
||||
note.event is ReactionEvent ||
|
||||
note.event is LnZapEvent ||
|
||||
note.event is LnZapRequestEvent ||
|
||||
note.event is FileHeaderEvent ||
|
||||
note.event is MetadataEvent ||
|
||||
note.event is ContactListEvent ||
|
||||
note.event is AppSpecificDataEvent
|
||||
)
|
||||
|
||||
/**
|
||||
* Tag names whose values should not match text searches: the `client` tag
|
||||
* names the app that published the event (searching for "Amethyst" would
|
||||
* otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt`
|
||||
* values are ids or descriptions of other events, not content of this one.
|
||||
*/
|
||||
private val excludedTagNamesFromSearch =
|
||||
setOf(
|
||||
ClientTag.TAG_NAME,
|
||||
PTag.TAG_NAME,
|
||||
ETag.TAG_NAME,
|
||||
ATag.TAG_NAME,
|
||||
AltTag.TAG_NAME,
|
||||
)
|
||||
|
||||
fun findNotesStartingWith(
|
||||
text: String,
|
||||
hiddenUsers: HiddenUsersState,
|
||||
): List<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
val key = decodeEventIdAsHexOrNull(text)
|
||||
|
||||
if (key != null) {
|
||||
val note = cache.getNoteIfExists(key)
|
||||
val noteEvent = note?.event
|
||||
val newNote =
|
||||
if (noteEvent is AddressableEvent) {
|
||||
val addressableNote = cache.getAddressableNoteIfExists(noteEvent.address())
|
||||
if (addressableNote?.event?.id == note.idHex) {
|
||||
addressableNote
|
||||
} else {
|
||||
note
|
||||
}
|
||||
} else {
|
||||
note
|
||||
}
|
||||
|
||||
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
|
||||
return listOfNotNull(newNote)
|
||||
}
|
||||
}
|
||||
|
||||
return cache.notes.filter { _, note ->
|
||||
if (note.event is AddressableEvent) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (excludeNoteEventFromSearchResults(note)) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
|
||||
note.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter !note.isHiddenFor(hiddenUsers.flow.value)
|
||||
}
|
||||
|
||||
if (note.event?.isContentEncoded() == false) {
|
||||
return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) {
|
||||
note.event?.content?.contains(text, true) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
return@filter false
|
||||
} +
|
||||
cache.addressables.filter { _, addressable ->
|
||||
if (excludeNoteEventFromSearchResults(addressable)) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
|
||||
addressable.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter !addressable.isHiddenFor(hiddenUsers.flow.value)
|
||||
}
|
||||
|
||||
if (addressable.event?.isContentEncoded() == false) {
|
||||
return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) {
|
||||
addressable.event?.content?.contains(text, true) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
return@filter false
|
||||
}
|
||||
}
|
||||
|
||||
fun findPublicChatChannelsStartingWith(text: String): List<PublicChatChannel> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
val key = decodeEventIdAsHexOrNull(text)
|
||||
if (key != null) {
|
||||
cache.getPublicChatChannelIfExists(key)?.let {
|
||||
return listOf(it)
|
||||
}
|
||||
}
|
||||
|
||||
return cache.publicChatChannels.filter { _, channel ->
|
||||
channel.anyNameStartsWith(text)
|
||||
}
|
||||
}
|
||||
|
||||
fun findEphemeralChatChannelsStartingWith(text: String): List<EphemeralChatChannel> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
return cache.ephemeralChannels.filter { _, channel ->
|
||||
channel.anyNameStartsWith(text)
|
||||
}
|
||||
}
|
||||
|
||||
fun findLiveActivityChannelsStartingWith(text: String): List<LiveActivitiesChannel> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
try {
|
||||
val parsed = Nip19Parser.uriToRoute(text)?.entity
|
||||
if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) {
|
||||
return listOf(cache.getOrCreateLiveChannel(parsed.address()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
|
||||
return cache.liveChatChannels.filter { _, channel ->
|
||||
channel.anyNameStartsWith(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* The minimal get-or-create surface of the event cache, used by callers (like
|
||||
* `NewMessageTagger`) that resolve user/note references while composing without
|
||||
* needing the full [LocalCache] API.
|
||||
*/
|
||||
interface Dao {
|
||||
fun getOrCreateUser(hex: HexKey): User
|
||||
|
||||
fun getOrCreateNote(hex: HexKey): Note
|
||||
|
||||
fun getOrCreateAddressableNote(address: Address): AddressableNote?
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
|
||||
/**
|
||||
* The sign-and-publish choke point for an [Account]: computes the relay set an
|
||||
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
|
||||
* relays, broadcast lists, DM inboxes) and owns every publish path - automatic,
|
||||
* outbox-only, everywhere, private-relay-list, anonymous, and rebroadcast.
|
||||
*
|
||||
* Feature orchestration on [Account] (and the Account*Actions classes) should
|
||||
* funnel every publish through this class instead of calling the relay client
|
||||
* directly.
|
||||
*/
|
||||
class EventBroadcaster(
|
||||
private val account: Account,
|
||||
) {
|
||||
private fun computeRelayListForLinkedUser(user: User): Set<NormalizedRelayUrl> =
|
||||
if (user == account.userProfile()) {
|
||||
account.notificationRelays.flow.value
|
||||
} else {
|
||||
user.inboxRelays()?.ifEmpty { null }?.toSet()
|
||||
?: (
|
||||
account.cache.relayHints
|
||||
.hintsForKey(user.pubkeyHex)
|
||||
.toSet() + user.allUsedRelays()
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeRelayListForLinkedUser(pubkey: HexKey): Set<NormalizedRelayUrl> =
|
||||
if (pubkey == account.userProfile().pubkeyHex) {
|
||||
account.notificationRelays.flow.value
|
||||
} else {
|
||||
account.cache
|
||||
.getUserIfExists(pubkey)
|
||||
?.inboxRelays()
|
||||
?.ifEmpty { null }
|
||||
?.toSet()
|
||||
?: account.cache.relayHints
|
||||
.hintsForKey(pubkey)
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun computeRelaysForChannels(event: Event): Set<NormalizedRelayUrl> = account.cache.getAnyChannel(event)?.relays() ?: emptySet()
|
||||
|
||||
// Personal events the user stores just for themselves — drafts, app settings, bookmark
|
||||
// lists — and channel/community events that already declare their own home relays
|
||||
// should not be replicated to the user's broadcasting relays. Channel/community events
|
||||
// that don't define any home relays fall through to broadcast, since there's nowhere
|
||||
// else for them to land.
|
||||
private fun wantsBroadcastRelays(event: Event): Boolean {
|
||||
if (event is DraftWrapEvent ||
|
||||
event is AppSpecificDataEvent ||
|
||||
event is BookmarkListEvent ||
|
||||
event is OldBookmarkListEvent ||
|
||||
event is LabeledBookmarkListEvent
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (event is PollEvent && event.relays().isNotEmpty()) return false
|
||||
if (event is MeetingSpaceEvent && event.allRelayUrls().isNotEmpty()) return false
|
||||
if (event is MeetingRoomEvent && event.allRelayUrls().isNotEmpty()) return false
|
||||
if (event is LiveActivitiesEvent && event.allRelayUrls().isNotEmpty()) return false
|
||||
|
||||
val channelRelays = account.cache.getAnyChannel(event)?.relays()
|
||||
if (channelRelays != null && channelRelays.isNotEmpty()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> = computeRelayListToBroadcast(event, mutableSetOf())
|
||||
|
||||
private fun computeRelayListToBroadcast(
|
||||
event: Event,
|
||||
visited: MutableSet<HexKey>,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
// a-tagged events can form cycles; without this the two recursive descents stack-overflow.
|
||||
if (!visited.add(event.id)) return emptySet()
|
||||
|
||||
if (event is GiftWrapEvent) {
|
||||
val receiver = event.recipientPubKey()
|
||||
return if (receiver != null) {
|
||||
val relayList =
|
||||
account.cache
|
||||
.getOrCreateUser(receiver)
|
||||
.dmInboxRelayList()
|
||||
?.relays()
|
||||
?.ifEmpty { null }
|
||||
relayList?.toSet() ?: computeRelayListForLinkedUser(receiver)
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
// Seals, inner DM messages, and unsigned rumors never get broadcast
|
||||
// relays: they only travel inside gift wraps.
|
||||
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
val includeBroadcast = wantsBroadcastRelays(event)
|
||||
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
|
||||
|
||||
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
|
||||
// everywhere
|
||||
return account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + broadcastRelays
|
||||
}
|
||||
|
||||
val relayList = mutableSetOf<NormalizedRelayUrl>()
|
||||
relayList.addAll(broadcastRelays)
|
||||
|
||||
val author = account.cache.getUserIfExists(event.pubKey)
|
||||
|
||||
if (author != null) {
|
||||
if (author == account.userProfile()) {
|
||||
if (includeBroadcast) {
|
||||
relayList.addAll(account.outboxRelays.flow.value)
|
||||
} else {
|
||||
// account.outboxRelays mixes in the broadcast list; for personal/channel events
|
||||
// we want the user's NIP-65 / private / local outbox without it.
|
||||
relayList.addAll(account.nip65RelayList.outboxFlow.value)
|
||||
relayList.addAll(account.privateStorageRelayList.flow.value)
|
||||
relayList.addAll(account.localRelayList.flow.value)
|
||||
}
|
||||
} else {
|
||||
val relays =
|
||||
author.outboxRelays()?.ifEmpty { null }
|
||||
?: author.allUsedRelaysOrNull()
|
||||
?: account.cache.relayHints.hintsForKey(author.pubkeyHex)
|
||||
|
||||
relayList.addAll(relays)
|
||||
}
|
||||
} else {
|
||||
relayList.addAll(account.cache.relayHints.hintsForKey(event.pubKey))
|
||||
}
|
||||
|
||||
if (event is PubKeyHintProvider) {
|
||||
event.pubKeyHints().forEach {
|
||||
relayList.add(it.relay)
|
||||
}
|
||||
event.linkedPubKeys().forEach { pubkey ->
|
||||
relayList.addAll(computeRelayListForLinkedUser(pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
if (event is EventHintProvider) {
|
||||
event.eventHints().forEach {
|
||||
relayList.add(it.relay)
|
||||
}
|
||||
event.linkedEventIds().forEach { eventId ->
|
||||
account.cache.getNoteIfExists(eventId)?.let { linkedNote ->
|
||||
val linkedNoteAuthor = linkedNote.author
|
||||
|
||||
if (linkedNoteAuthor != null) {
|
||||
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
|
||||
} else {
|
||||
relayList.addAll(linkedNote.relays.toSet())
|
||||
}
|
||||
|
||||
linkedNote.event?.let { linkedEvent ->
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event is AddressHintProvider) {
|
||||
event.addressHints().forEach {
|
||||
relayList.add(it.relay)
|
||||
}
|
||||
event.linkedAddressIds().forEach { addressId ->
|
||||
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
|
||||
val linkedNoteAuthor = linkedNote.author
|
||||
|
||||
if (linkedNoteAuthor != null) {
|
||||
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
|
||||
} else {
|
||||
relayList.addAll(linkedNote.relays.toSet())
|
||||
}
|
||||
|
||||
linkedNote.event?.let { linkedEvent ->
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event is PollEvent) {
|
||||
relayList.addAll(event.relays())
|
||||
}
|
||||
|
||||
if (event is MeetingSpaceEvent) {
|
||||
relayList.addAll(event.allRelayUrls())
|
||||
}
|
||||
|
||||
if (event is MeetingRoomEvent) {
|
||||
relayList.addAll(event.allRelayUrls())
|
||||
}
|
||||
|
||||
if (event is LiveActivitiesEvent) {
|
||||
relayList.addAll(event.allRelayUrls())
|
||||
}
|
||||
|
||||
relayList.addAll(computeRelaysForChannels(event))
|
||||
|
||||
return relayList
|
||||
}
|
||||
|
||||
fun computeRelayListToBroadcast(note: Note): Set<NormalizedRelayUrl> {
|
||||
val noteEvent = note.event
|
||||
return if (noteEvent != null) {
|
||||
computeRelayListToBroadcast(noteEvent)
|
||||
} else {
|
||||
note.relays.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun broadcast(note: Note) {
|
||||
note.event?.let { noteEvent ->
|
||||
val host = note.rumorHost
|
||||
if (host != null) {
|
||||
// Rumors are rebroadcast as their delivering envelope: the
|
||||
// cached copy is content-stripped, so download it and send it.
|
||||
// A just-sent note has no relays until its self-wrap echoes
|
||||
// back — fall back to our own DM inbox relays. Bare seals
|
||||
// (kind 13) carry no p tag, so that filter is wrap-only.
|
||||
val relays =
|
||||
note.relays.ifEmpty {
|
||||
account.dmRelays.flow.value
|
||||
.toList()
|
||||
}
|
||||
val filter =
|
||||
if (host.kind == SealedRumorEvent.KIND) {
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
ids = listOf(host.id),
|
||||
)
|
||||
} else {
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
tags = mapOf("p" to listOf(account.pubKey)),
|
||||
ids = listOf(host.id),
|
||||
)
|
||||
}
|
||||
account.client
|
||||
.fetchFirst(
|
||||
filters = relays.associateWith { _ -> listOf(filter) },
|
||||
)?.let { downloadedEvent ->
|
||||
val toRelays = computeRelayListToBroadcast(downloadedEvent)
|
||||
account.client.publish(downloadedEvent, toRelays)
|
||||
}
|
||||
} else if (noteEvent.sig.isEmpty()) {
|
||||
// Rumor with no known wrap: publishing it would disclose the
|
||||
// private content to relays even though they reject the
|
||||
// missing signature.
|
||||
return
|
||||
} else {
|
||||
account.client.publish(noteEvent, computeRelayListToBroadcast(note))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
|
||||
|
||||
fun sendAutomatic(event: Event?) {
|
||||
if (event == null) return
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, computeRelayListToBroadcast(event))
|
||||
}
|
||||
|
||||
fun sendMyPublicAndPrivateOutbox(event: Event?) {
|
||||
if (event == null) return
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, account.outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
fun sendMyPublicAndPrivateOutbox(events: List<Event>) {
|
||||
events.forEach {
|
||||
account.client.publish(it, account.outboxRelays.flow.value)
|
||||
account.cache.justConsumeMyOwnEvent(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLiterallyEverywhere(event: Event) {
|
||||
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAndSendPrivately(
|
||||
template: EventTemplate<T>,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val event = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, relayList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral
|
||||
* identity that is deliberately NOT this account's key) and publish to exactly
|
||||
* [relayList]. Used by geohash location chat, where authorship inside a cell
|
||||
* must not be linkable to the user's npub.
|
||||
*/
|
||||
suspend fun <T : Event> signWithAndSendPrivately(
|
||||
template: EventTemplate<T>,
|
||||
signer: NostrSigner,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
): T {
|
||||
val event = signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
if (relayList.isNotEmpty()) account.client.publish(event, relayList)
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAndSendPrivatelyOrBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
relayList: (T) -> List<NormalizedRelayUrl>?,
|
||||
): T {
|
||||
val event = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
val relays = relayList(event)
|
||||
val targets =
|
||||
if (!relays.isNullOrEmpty()) {
|
||||
relays.toSet()
|
||||
} else {
|
||||
computeRelayListToBroadcast(event)
|
||||
}
|
||||
account.chatDeliveryTracker.trackPublic(event.id, targets)
|
||||
account.client.publish(event, targets)
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAndComputeBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
broadcast: List<Event> = emptyList(),
|
||||
): T {
|
||||
val event = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
val note =
|
||||
if (event is AddressableEvent) {
|
||||
account.cache.getOrCreateAddressableNote(event.address())
|
||||
} else {
|
||||
account.cache.getOrCreateNote(event.id)
|
||||
}
|
||||
|
||||
val relayList = computeRelayListToBroadcast(note)
|
||||
|
||||
account.client.publish(event, relayList)
|
||||
|
||||
broadcast.forEach { account.client.publish(it, relayList) }
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAnonymouslyAndBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
broadcast: List<Event> = emptyList(),
|
||||
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
|
||||
): T {
|
||||
val event = anonymousSigner.sign(template)
|
||||
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
val note =
|
||||
if (event is AddressableEvent) {
|
||||
account.cache.getOrCreateAddressableNote(event.address())
|
||||
} else {
|
||||
account.cache.getOrCreateNote(event.id)
|
||||
}
|
||||
|
||||
val relayList = computeRelayListToBroadcast(note)
|
||||
|
||||
account.client.publish(event, relayList)
|
||||
|
||||
broadcast.forEach { account.client.publish(it, relayList) }
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
fun republishEventsTo(
|
||||
events: List<Event>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
if (relays.isEmpty() || events.isEmpty()) return
|
||||
events.forEach { account.client.publish(it, relays) }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -279,7 +279,7 @@ class AccountNappletGateways(
|
||||
}
|
||||
|
||||
val result = CompletableDeferred<String?>()
|
||||
account.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
when (response) {
|
||||
is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage)
|
||||
is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed."))
|
||||
|
||||
@@ -159,7 +159,7 @@ class V4VPaymentHandler(
|
||||
tlvRecords = tlvRecords,
|
||||
)
|
||||
|
||||
account.sendNwcRequest(request) { response: Response? ->
|
||||
account.zaps.sendNwcRequest(request) { response: Response? ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
@@ -195,7 +195,7 @@ class V4VPaymentHandler(
|
||||
try {
|
||||
val nostrRequest =
|
||||
if (asZap && noteEvent != null) {
|
||||
account.createZapRequestFor(
|
||||
account.zaps.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = null,
|
||||
message = message,
|
||||
@@ -250,7 +250,7 @@ class V4VPaymentHandler(
|
||||
is PaymentSource.Nwc -> {
|
||||
var done = 0
|
||||
payables.forEach { payable ->
|
||||
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
|
||||
@@ -163,7 +163,7 @@ class ZapPaymentHandler(
|
||||
val canBolt12 =
|
||||
account.settings.nwcWallets.value
|
||||
.isNotEmpty() &&
|
||||
account.defaultWalletSupportsBolt12Pay()
|
||||
account.zaps.defaultWalletSupportsBolt12Pay()
|
||||
|
||||
val bolt12Recipients =
|
||||
unverifiedZapsToSend.mapNotNull {
|
||||
@@ -330,7 +330,7 @@ class ZapPaymentHandler(
|
||||
|
||||
val zapRequest =
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
|
||||
account.createZapRequestFor(
|
||||
account.zaps.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
@@ -414,7 +414,7 @@ class ZapPaymentHandler(
|
||||
return mapNotNullAsync(
|
||||
items = payables,
|
||||
runRequestFor = { payable: Payable ->
|
||||
account.sendZapPaymentRequestFor(
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = note,
|
||||
onResponse = { response ->
|
||||
@@ -462,7 +462,7 @@ class ZapPaymentHandler(
|
||||
val progress = PaymentProgress(recipients.size, onProgress)
|
||||
|
||||
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
|
||||
account.sendBolt12Zap(
|
||||
account.zaps.sendBolt12Zap(
|
||||
zappedEvent = note.event,
|
||||
recipientPubKey = recipient.user.pubkeyHex,
|
||||
offer = recipient.offer,
|
||||
|
||||
+8
-8
@@ -54,21 +54,21 @@ class MemoryTrimmingService(
|
||||
) {
|
||||
// Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are
|
||||
// not currently held by the UI, so it is safe and inexpensive at any pressure level.
|
||||
cache.cleanMemory()
|
||||
cache.cleanObservers()
|
||||
cache.pruneExpiredEvents()
|
||||
cache.prunePastVersionsOfReplaceables()
|
||||
cache.pruner.cleanMemory()
|
||||
cache.pruner.cleanObservers()
|
||||
cache.pruner.pruneExpiredEvents()
|
||||
cache.pruner.prunePastVersionsOfReplaceables()
|
||||
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
|
||||
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
|
||||
// messages, and unobserved reactions.
|
||||
account.forEach {
|
||||
cache.pruneHiddenEvents(it)
|
||||
cache.pruneHiddenMessages(it)
|
||||
cache.pruner.pruneHiddenEvents(it)
|
||||
cache.pruner.pruneHiddenMessages(it)
|
||||
}
|
||||
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
|
||||
cache.pruneOldMessages()
|
||||
cache.pruneRepliesAndReactions(accounts)
|
||||
cache.pruner.pruneOldMessages()
|
||||
cache.pruner.pruneRepliesAndReactions(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
persistOwn = false,
|
||||
)
|
||||
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
|
||||
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
|
||||
}
|
||||
|
||||
private suspend fun sendPublicReply(
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ object BlossomPaymentHandler {
|
||||
|
||||
val preimageResult = CompletableDeferred<String?>()
|
||||
try {
|
||||
account.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
|
||||
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Dao
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
@@ -258,11 +257,3 @@ class NewMessageTagger(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface Dao {
|
||||
fun getOrCreateUser(hex: HexKey): User
|
||||
|
||||
fun getOrCreateNote(hex: HexKey): Note
|
||||
|
||||
fun getOrCreateAddressableNote(address: Address): AddressableNote?
|
||||
}
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ fun ConcordInviteCard(
|
||||
|
||||
// Peek the bundle once per link to reveal the community name (null until it resolves).
|
||||
val invite by produceState<CommunityInvite?>(initialValue = null, linkText) {
|
||||
value = accountViewModel.account.peekConcordInvite(linkText)
|
||||
value = accountViewModel.account.concord.peekConcordInvite(linkText)
|
||||
}
|
||||
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -275,8 +275,8 @@ fun CardBody(
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
|
||||
|
||||
// Concord moderation: only present when this account may actually act.
|
||||
val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null }
|
||||
val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) }
|
||||
val canConcordBan = remember(note) { accountViewModel.account.concord.concordBanTarget(note) != null }
|
||||
val concordAdmin = remember(note) { accountViewModel.account.concord.concordAdminTarget(note) }
|
||||
val showConcordBanDialog = remember { mutableStateOf(false) }
|
||||
|
||||
if (showConcordBanDialog.value) {
|
||||
|
||||
@@ -103,7 +103,7 @@ class PollNoteViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByLoggedInAccount = false
|
||||
wasZappedByLoggedInAccount = account.calculateIfNoteWasZappedByAccount(pollNote, 0)
|
||||
wasZappedByLoggedInAccount = account.zaps.calculateIfNoteWasZappedByAccount(pollNote, 0)
|
||||
canZap.value = checkIfCanZap()
|
||||
|
||||
tallies.forEach {
|
||||
|
||||
+1
-1
@@ -190,7 +190,7 @@ class UserSuggestionState(
|
||||
if (prefix != null) {
|
||||
logTime("UserSuggestionState Search $prefix version $version") {
|
||||
rankPriorityFirst(
|
||||
account.cache.findUsersStartingWith(prefix, account),
|
||||
account.cache.search.findUsersStartingWith(prefix, account),
|
||||
priorityPubkeys(),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -371,7 +371,7 @@ fun noteActionSections(
|
||||
// message's author (both return null unless it's a Concord message this
|
||||
// account may act on). Promote/demote is instant; a ban re-keys the
|
||||
// community, so it defers to the surface's confirmation dialog.
|
||||
val concordAdmin = accountViewModel.account.concordAdminTarget(note)
|
||||
val concordAdmin = accountViewModel.account.concord.concordAdminTarget(note)
|
||||
if (concordAdmin != null) {
|
||||
val isAdmin = concordAdmin.third
|
||||
add(
|
||||
@@ -384,7 +384,7 @@ fun noteActionSections(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) {
|
||||
if (handlers.onConcordBan != null && accountViewModel.account.concord.concordBanTarget(note) != null) {
|
||||
add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ fun GoalProgressBar(
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
zapsState?.note?.let {
|
||||
val newZapAmount = accountViewModel.account.calculateZappedAmount(note)
|
||||
val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note)
|
||||
var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat()
|
||||
if (percentage > 1) percentage = 1f
|
||||
|
||||
|
||||
+57
-57
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Dao
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
||||
@@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismis
|
||||
import com.vitorpamplona.amethyst.service.pow.powKindLabelRes
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.actions.Dao
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
|
||||
@@ -548,7 +548,7 @@ class AccountViewModel(
|
||||
// public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an
|
||||
// existing Concord reaction is a follow-up; for now this only adds one.)
|
||||
if (note.inGatherers?.any { it is ConcordChannel } == true) {
|
||||
launchSigner { account.reactToConcordMessage(note, reaction) }
|
||||
launchSigner { account.concord.reactToConcordMessage(note, reaction) }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -606,15 +606,15 @@ class AccountViewModel(
|
||||
|
||||
/** Ban the author of a Concord channel message (no-op unless this account may ban them). */
|
||||
fun banConcordMember(note: Note) {
|
||||
val (communityId, member) = account.concordBanTarget(note) ?: return
|
||||
launchSigner { account.banConcordMember(communityId, member) }
|
||||
val (communityId, member) = account.concord.concordBanTarget(note) ?: return
|
||||
launchSigner { account.concord.banConcordMember(communityId, member) }
|
||||
}
|
||||
|
||||
/** Toggle the Admin role on the author of a Concord channel message (owner only). */
|
||||
fun toggleConcordAdmin(note: Note) {
|
||||
val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return
|
||||
val (communityId, member, isAdmin) = account.concord.concordAdminTarget(note) ?: return
|
||||
launchSigner {
|
||||
if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member)
|
||||
if (isAdmin) account.concord.removeConcordAdmin(communityId, member) else account.concord.makeConcordAdmin(communityId, member)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ class AccountViewModel(
|
||||
member: HexKey,
|
||||
makeAdmin: Boolean,
|
||||
) = launchSigner {
|
||||
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
|
||||
if (makeAdmin) account.concord.makeConcordAdmin(communityId, member) else account.concord.removeConcordAdmin(communityId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -640,7 +640,7 @@ class AccountViewModel(
|
||||
member: HexKey,
|
||||
roleIds: List<String>,
|
||||
) = launchSigner {
|
||||
if (!account.grantConcordRole(communityId, member, roleIds)) {
|
||||
if (!account.concord.grantConcordRole(communityId, member, roleIds)) {
|
||||
toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed)
|
||||
}
|
||||
}
|
||||
@@ -651,7 +651,7 @@ class AccountViewModel(
|
||||
member: HexKey,
|
||||
ban: Boolean,
|
||||
) = launchSigner {
|
||||
if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member)
|
||||
if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -663,7 +663,7 @@ class AccountViewModel(
|
||||
communityId: String,
|
||||
member: HexKey,
|
||||
) = launchSigner {
|
||||
account.refoundConcordCommunity(communityId, setOf(member))
|
||||
account.concord.refoundConcordCommunity(communityId, setOf(member))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -683,7 +683,7 @@ class AccountViewModel(
|
||||
else -> emptyList()
|
||||
}
|
||||
}.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
account.importConcordCommunities(pinnedRelays)
|
||||
account.concord.importConcordCommunities(pinnedRelays)
|
||||
}
|
||||
|
||||
/** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */
|
||||
@@ -691,12 +691,12 @@ class AccountViewModel(
|
||||
communityId: String,
|
||||
channelIdHex: String,
|
||||
) = viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendConcordTyping(communityId, channelIdHex)
|
||||
account.concord.sendConcordTyping(communityId, channelIdHex)
|
||||
}
|
||||
|
||||
fun sendBuzzTyping(channel: RelayGroupChannel) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendBuzzTyping(channel)
|
||||
account.relayGroups.sendBuzzTyping(channel)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@@ -843,7 +843,7 @@ class AccountViewModel(
|
||||
afterTimeInSeconds: Long,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
|
||||
account.zaps.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
|
||||
}
|
||||
|
||||
suspend fun calculateZapAmount(zappedNote: Note): String {
|
||||
@@ -854,7 +854,7 @@ class AccountViewModel(
|
||||
val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex)
|
||||
return if (zappedNote.zapPayments.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val nwc = account.calculateZappedAmount(zappedNote)
|
||||
val nwc = account.zaps.calculateZappedAmount(zappedNote)
|
||||
showAmount(nwc + java.math.BigDecimal(ownPendingOnchain))
|
||||
}
|
||||
} else {
|
||||
@@ -866,7 +866,7 @@ class AccountViewModel(
|
||||
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
|
||||
return if (zappedNote.zapPayments.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newZapAmount = account.calculateZappedAmount(zappedNote)
|
||||
val newZapAmount = account.zaps.calculateZappedAmount(zappedNote)
|
||||
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
|
||||
|
||||
if (percentage > 1) {
|
||||
@@ -1202,7 +1202,7 @@ class AccountViewModel(
|
||||
.isNotEmpty()
|
||||
|
||||
/** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */
|
||||
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay()
|
||||
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay()
|
||||
|
||||
/**
|
||||
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
|
||||
@@ -1214,7 +1214,7 @@ class AccountViewModel(
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
) = launchSigner {
|
||||
account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
account.zaps.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
when (response) {
|
||||
is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent)
|
||||
is IErrorResponseLike ->
|
||||
@@ -1668,12 +1668,12 @@ class AccountViewModel(
|
||||
fun joinRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
code: String? = null,
|
||||
) = launchSigner { account.joinRelayGroup(channel, code) }
|
||||
) = launchSigner { account.relayGroups.joinRelayGroup(channel, code) }
|
||||
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) }
|
||||
|
||||
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
|
||||
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
|
||||
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) }
|
||||
|
||||
/**
|
||||
* Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without
|
||||
@@ -1682,7 +1682,7 @@ class AccountViewModel(
|
||||
fun archiveRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
archived: Boolean,
|
||||
) = launchSigner { account.archiveRelayGroup(channel, archived) }
|
||||
) = launchSigner { account.relayGroups.archiveRelayGroup(channel, archived) }
|
||||
|
||||
/**
|
||||
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
|
||||
@@ -1721,7 +1721,7 @@ class AccountViewModel(
|
||||
* Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay
|
||||
* republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it.
|
||||
*/
|
||||
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) }
|
||||
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.relayGroups.hideBuzzDm(channel) }
|
||||
|
||||
/**
|
||||
* Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with
|
||||
@@ -1731,7 +1731,7 @@ class AccountViewModel(
|
||||
fun unhideBuzzDm(
|
||||
relay: NormalizedRelayUrl,
|
||||
participants: List<HexKey>,
|
||||
) = launchSigner { account.openBuzzDm(relay, participants) }
|
||||
) = launchSigner { account.relayGroups.openBuzzDm(relay, participants) }
|
||||
|
||||
/**
|
||||
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
|
||||
@@ -1745,7 +1745,7 @@ class AccountViewModel(
|
||||
/** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */
|
||||
fun leaveChannelInvite(channel: RelayGroupChannel) =
|
||||
launchSigner {
|
||||
account.leaveRelayGroup(channel)
|
||||
account.relayGroups.leaveRelayGroup(channel)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
|
||||
}
|
||||
|
||||
@@ -1756,7 +1756,7 @@ class AccountViewModel(
|
||||
* what makes leaving a community whose own relays are dead work at all — the list lives in *our*
|
||||
* outbox, not in the community's relays.
|
||||
*/
|
||||
fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) }
|
||||
fun leaveConcordCommunity(communityId: String) = launchSigner { account.concord.leaveConcordCommunity(communityId) }
|
||||
|
||||
fun createRelayGroup(
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -1771,7 +1771,7 @@ class AccountViewModel(
|
||||
hashtags: List<String>,
|
||||
geohashes: List<String>,
|
||||
) = launchSigner {
|
||||
account.createRelayGroup(
|
||||
account.relayGroups.createRelayGroup(
|
||||
relay,
|
||||
groupId,
|
||||
name,
|
||||
@@ -1789,47 +1789,47 @@ class AccountViewModel(
|
||||
fun createRelayGroupInvite(
|
||||
channel: RelayGroupChannel,
|
||||
code: String,
|
||||
) = launchSigner { account.createRelayGroupInvite(channel, code) }
|
||||
) = launchSigner { account.relayGroups.createRelayGroupInvite(channel, code) }
|
||||
|
||||
fun postRelayGroupThread(
|
||||
channel: RelayGroupChannel,
|
||||
title: String,
|
||||
body: String,
|
||||
) = launchSigner { account.postRelayGroupThread(channel, title, body) }
|
||||
) = launchSigner { account.relayGroups.postRelayGroupThread(channel, title, body) }
|
||||
|
||||
fun pinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
note: Note,
|
||||
) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) }
|
||||
) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) }
|
||||
|
||||
fun unpinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
note: Note,
|
||||
) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) }
|
||||
) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) }
|
||||
|
||||
fun removeRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
) = launchSigner { account.removeRelayGroupUser(channel, pubkey) }
|
||||
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
|
||||
|
||||
fun putRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
roles: List<String>,
|
||||
) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) }
|
||||
) = launchSigner { account.relayGroups.putRelayGroupUser(channel, pubkey, roles) }
|
||||
|
||||
/** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */
|
||||
fun addCommunityMember(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey,
|
||||
role: String? = null,
|
||||
) = launchSigner { account.addCommunityMember(relay, pubkey, role) }
|
||||
) = launchSigner { account.relayGroups.addCommunityMember(relay, pubkey, role) }
|
||||
|
||||
/** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */
|
||||
fun removeCommunityMember(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey,
|
||||
) = launchSigner { account.removeCommunityMember(relay, pubkey) }
|
||||
) = launchSigner { account.relayGroups.removeCommunityMember(relay, pubkey) }
|
||||
|
||||
fun editRelayGroupMetadata(
|
||||
channel: RelayGroupChannel,
|
||||
@@ -1843,7 +1843,7 @@ class AccountViewModel(
|
||||
hashtags: List<String>,
|
||||
geohashes: List<String>,
|
||||
) = launchSigner {
|
||||
account.editRelayGroupMetadata(
|
||||
account.relayGroups.editRelayGroupMetadata(
|
||||
channel,
|
||||
name,
|
||||
about,
|
||||
@@ -2330,8 +2330,8 @@ class AccountViewModel(
|
||||
mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(),
|
||||
)
|
||||
?: return
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||
}
|
||||
|
||||
suspend fun sendMarmotGroupMediaMessage(
|
||||
@@ -2356,21 +2356,21 @@ class AccountViewModel(
|
||||
account.signer.pubKey,
|
||||
template,
|
||||
)
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
||||
}
|
||||
|
||||
fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId)
|
||||
|
||||
suspend fun createMarmotGroup(nostrGroupId: String) {
|
||||
account.createMarmotGroup(nostrGroupId)
|
||||
account.marmot.createMarmotGroup(nostrGroupId)
|
||||
}
|
||||
|
||||
suspend fun publishMarmotKeyPackage() {
|
||||
account.publishMarmotKeyPackage()
|
||||
account.marmot.publishMarmotKeyPackage()
|
||||
}
|
||||
|
||||
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
|
||||
suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage()
|
||||
|
||||
/**
|
||||
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
|
||||
@@ -2394,12 +2394,12 @@ class AccountViewModel(
|
||||
}
|
||||
|
||||
suspend fun leaveMarmotGroup(nostrGroupId: String) {
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.leaveMarmotGroup(nostrGroupId, relays)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.leaveMarmotGroup(nostrGroupId, relays)
|
||||
}
|
||||
|
||||
suspend fun resetMarmotState() {
|
||||
account.resetMarmotState()
|
||||
account.marmot.resetMarmotState()
|
||||
}
|
||||
|
||||
fun marmotGroupMembers(nostrGroupId: String): List<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList()
|
||||
@@ -2407,30 +2407,30 @@ class AccountViewModel(
|
||||
suspend fun addMarmotGroupMember(
|
||||
nostrGroupId: String,
|
||||
memberPubKey: String,
|
||||
): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
|
||||
): String = account.marmot.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey)
|
||||
|
||||
suspend fun removeMarmotGroupMember(
|
||||
nostrGroupId: String,
|
||||
targetLeafIndex: Int,
|
||||
) {
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
|
||||
}
|
||||
|
||||
suspend fun grantMarmotGroupAdmin(
|
||||
nostrGroupId: String,
|
||||
targetPubKey: String,
|
||||
) {
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
}
|
||||
|
||||
suspend fun revokeMarmotGroupAdmin(
|
||||
nostrGroupId: String,
|
||||
targetPubKey: String,
|
||||
) {
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2486,8 +2486,8 @@ class AccountViewModel(
|
||||
imageUploadKey = icon.upload.imageUploadKey,
|
||||
)
|
||||
}
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||
val relays = account.marmot.marmotGroupRelays(nostrGroupId)
|
||||
account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -2745,7 +2745,7 @@ class AccountViewModel(
|
||||
onSent: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
) = launchSigner {
|
||||
account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
onSent()
|
||||
}
|
||||
|
||||
@@ -2801,7 +2801,7 @@ class AccountViewModel(
|
||||
if (effectiveZapType != LnZapEvent.ZapType.NONZAP) {
|
||||
// NIP-57 Appendix F: include amount + lnurl so the receipt can be validated.
|
||||
val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32)
|
||||
account.createZapRequestFor(
|
||||
account.zaps.createZapRequestFor(
|
||||
user = user,
|
||||
message = message,
|
||||
zapType = effectiveZapType,
|
||||
|
||||
+2
-2
@@ -308,7 +308,7 @@ class GiftWrapEventHandler(
|
||||
// already folded the state they carried, so drop the durable wrap note now
|
||||
// to keep LocalCache from growing without bound.
|
||||
if (event is EphemeralGiftWrapEvent) {
|
||||
cache.unlinkAndRemove(listOf(eventNote))
|
||||
cache.pruner.unlinkAndRemove(listOf(eventNote))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -415,7 +415,7 @@ private suspend fun processMarmotWelcomeFlow(
|
||||
|
||||
// Rotate KeyPackages if needed
|
||||
if (result.needsKeyPackageRotation) {
|
||||
account.publishMarmotKeyPackages()
|
||||
account.marmot.publishMarmotKeyPackages()
|
||||
}
|
||||
|
||||
// Fire the "You've been added to <group>" notification. Welcomes
|
||||
|
||||
+4
-1
@@ -436,7 +436,10 @@ private fun AgentKeyPicker(
|
||||
delay(150)
|
||||
suggestions =
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8)
|
||||
LocalCache.search
|
||||
.findUsersStartingWith(query.trim(), accountViewModel.account)
|
||||
.map { it.pubkeyHex }
|
||||
.take(8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -169,33 +169,33 @@ class AgentWorkBoardViewModel : ViewModel() {
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, channelId ->
|
||||
if (requireApproval) {
|
||||
account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
|
||||
account.relayGroups.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null
|
||||
} else {
|
||||
account.fileBuzzJob(relay, channelId, text) != null
|
||||
account.relayGroups.fileBuzzJob(relay, channelId, text) != null
|
||||
}
|
||||
}
|
||||
|
||||
fun approve(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null }
|
||||
) = act(onResult) { account, relay, _ -> account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null }
|
||||
|
||||
fun deny(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null }
|
||||
) = act(onResult) { account, relay, _ -> account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null }
|
||||
|
||||
fun upvote(
|
||||
jobId: HexKey,
|
||||
jobAuthor: HexKey?,
|
||||
) = act({}) { account, relay, channelId ->
|
||||
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
true
|
||||
}
|
||||
|
||||
fun cancel(jobId: HexKey) =
|
||||
act({}) { account, relay, channelId ->
|
||||
account.cancelBuzzJob(relay, channelId, jobId)
|
||||
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -211,7 +211,7 @@ private fun DmRowCard(
|
||||
addMemberOpen = false
|
||||
scope.launch {
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
|
||||
accountViewModel.account.addBuzzDmMember(channel, hex)
|
||||
accountViewModel.account.relayGroups.addBuzzDmMember(channel, hex)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+2
-2
@@ -254,7 +254,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
fun removeFromMessages(row: DmRow) {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
|
||||
account.relayGroups.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val me = account.userProfile().pubkeyHex
|
||||
account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
|
||||
account.relayGroups.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
|
||||
// The relay's new 30622 normally arrives on the live subscription; refresh anyway so the
|
||||
// row returns even if this screen's socket missed the snapshot.
|
||||
refresh()
|
||||
|
||||
+2
-2
@@ -118,7 +118,7 @@ class BuzzNewDmViewModel : ViewModel() {
|
||||
val me = account.userProfile().pubkeyHex
|
||||
val already = _participants.value.toSet()
|
||||
val ranked =
|
||||
LocalCache
|
||||
LocalCache.search
|
||||
.findUsersStartingWith(text.trim(), account)
|
||||
.asSequence()
|
||||
.map { it.pubkeyHex }
|
||||
@@ -194,7 +194,7 @@ class BuzzNewDmViewModel : ViewModel() {
|
||||
_status.value = Status.Sending
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val channelId = account.openBuzzDm(relay, others)
|
||||
val channelId = account.relayGroups.openBuzzDm(relay, others)
|
||||
val groupId = channelId?.let { GroupId(it, relay) }
|
||||
withContext(Dispatchers.Main) { onOpened(groupId) }
|
||||
} catch (e: CancellationException) {
|
||||
|
||||
+3
-3
@@ -112,19 +112,19 @@ class JobBoardViewModel : ViewModel() {
|
||||
|
||||
fun file(request: String) =
|
||||
act { account, relay, channelId ->
|
||||
account.fileBuzzJob(relay, channelId, request)
|
||||
account.relayGroups.fileBuzzJob(relay, channelId, request)
|
||||
}
|
||||
|
||||
fun upvote(
|
||||
jobId: String,
|
||||
jobAuthor: String?,
|
||||
) = act { account, relay, channelId ->
|
||||
account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor)
|
||||
}
|
||||
|
||||
fun cancel(jobId: String) =
|
||||
act { account, relay, channelId ->
|
||||
account.cancelBuzzJob(relay, channelId, jobId)
|
||||
account.relayGroups.cancelBuzzJob(relay, channelId, jobId)
|
||||
}
|
||||
|
||||
private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) {
|
||||
|
||||
+4
-4
@@ -199,21 +199,21 @@ class WorkflowRunBoardViewModel : ViewModel() {
|
||||
task: String,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, channelId ->
|
||||
account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
|
||||
account.relayGroups.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null
|
||||
}
|
||||
|
||||
fun approve(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ ->
|
||||
account.approveBuzzWorkflowRun(relay, runId) != null
|
||||
account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null
|
||||
}
|
||||
|
||||
fun deny(
|
||||
runId: HexKey,
|
||||
onResult: (Boolean) -> Unit,
|
||||
) = act(onResult) { account, relay, _ ->
|
||||
account.denyBuzzWorkflowRun(relay, runId) != null
|
||||
account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +234,7 @@ class WorkflowRunBoardViewModel : ViewModel() {
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml)
|
||||
val newId = account.relayGroups.publishBuzzWorkflowDef(relay, channelId, name, yaml)
|
||||
withContext(Dispatchers.Main) { onResult(newId) }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -190,9 +190,9 @@ fun ConcordChannelListScreen(
|
||||
channelEditor = null
|
||||
scope.launch {
|
||||
if (editor.channelIdHex == null) {
|
||||
account.createConcordChannel(communityId, newName)
|
||||
account.concord.createConcordChannel(communityId, newName)
|
||||
} else {
|
||||
account.renameConcordChannel(communityId, editor.channelIdHex, newName)
|
||||
account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -208,7 +208,7 @@ fun ConcordChannelListScreen(
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
channelToDelete = null
|
||||
scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) }
|
||||
scope.launch { account.concord.deleteConcordChannel(communityId, id, target.initialName) }
|
||||
}) {
|
||||
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm))
|
||||
}
|
||||
@@ -254,7 +254,7 @@ fun ConcordChannelListScreen(
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.mintConcordInvite(communityId)
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
|
||||
+1
-1
@@ -596,7 +596,7 @@ private fun ConcordFileUploadDialog(
|
||||
onceUploaded = { uploads ->
|
||||
val imetas = uploads.mapNotNull { it.toConcordImeta() }
|
||||
if (imetas.isNotEmpty()) {
|
||||
accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas)
|
||||
accountViewModel.account.concord.sendConcordChannelImageMessage(community, channel, "", imetas)
|
||||
}
|
||||
onUpload()
|
||||
},
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ fun ConcordCreateScreen(
|
||||
scope.launch {
|
||||
val communityId =
|
||||
try {
|
||||
accountViewModel.account.createConcordCommunity(
|
||||
accountViewModel.account.concord.createConcordCommunity(
|
||||
name = name.value.trim(),
|
||||
description = about.value.trim().ifBlank { null },
|
||||
relays = relays.map { it.url },
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ fun ConcordEditScreen(
|
||||
scope.launch {
|
||||
val ok =
|
||||
try {
|
||||
account.editConcordMetadata(
|
||||
account.concord.editConcordMetadata(
|
||||
communityId = communityId,
|
||||
name = name.value.trim(),
|
||||
description = about.value.trim().ifBlank { null },
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ fun ConcordInviteScreen(
|
||||
LaunchedEffect(link, state) {
|
||||
if (state is RedeemState.Working) {
|
||||
state =
|
||||
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
|
||||
when (val result = accountViewModel.account.concord.joinConcordViaInvite(link)) {
|
||||
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
|
||||
is ConcordInviteResult.InvalidLink ->
|
||||
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ fun ConcordChannelPreviewLoader(
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return@LaunchedEffect
|
||||
account.warmConcordChannelPreviews(listOf(entry))
|
||||
account.concord.warmConcordChannelPreviews(listOf(entry))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,6 @@ fun ConcordChannelPreviewAccountPreload(accountViewModel: AccountViewModel) {
|
||||
LaunchedEffect(communities, revision) {
|
||||
// Debounce the cold-boot burst of fold revisions (and any join/leave churn) into one drain.
|
||||
delay(1500)
|
||||
account.warmConcordChannelPreviews(communities)
|
||||
account.concord.warmConcordChannelPreviews(communities)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -150,7 +150,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) {
|
||||
|
||||
// (1) Load + membership/epoch change: one complete sweep of the whole set.
|
||||
LaunchedEffect(sig) {
|
||||
if (communities.isNotEmpty()) account.syncConcordControlPlanes(communities)
|
||||
if (communities.isNotEmpty()) account.concord.syncConcordControlPlanes(communities)
|
||||
}
|
||||
|
||||
// (2) Reconnect: re-sweep when a relay of ours transitions disconnected → connected.
|
||||
@@ -174,7 +174,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) {
|
||||
val now = TimeUtils.nowMillis()
|
||||
if (now - lastSweep < RECONNECT_RESWEEP_MIN_INTERVAL_MS) return@collect
|
||||
lastSweep = now
|
||||
account.syncConcordControlPlanes(liveCommunities)
|
||||
account.concord.syncConcordControlPlanes(liveCommunities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -204,11 +204,11 @@ open class ConcordNewMessageViewModel : ViewModel() {
|
||||
|
||||
val editing = editingMessage.value
|
||||
if (editing != null) {
|
||||
account.editConcordChannelMessage(editing, text)
|
||||
account.concord.editConcordChannelMessage(editing, text)
|
||||
editingMessage.value = null
|
||||
} else {
|
||||
val parent = replyTo.value
|
||||
account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
|
||||
account.concord.sendConcordChannelMessage(community, channel, text, parent, replyMode.value)
|
||||
}
|
||||
|
||||
message.clearText()
|
||||
|
||||
+2
-2
@@ -254,7 +254,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
|
||||
val geohashes = parseGeohashes()
|
||||
val existing = channel
|
||||
if (existing == null) {
|
||||
account.createRelayGroup(
|
||||
account.relayGroups.createRelayGroup(
|
||||
relay = relay!!,
|
||||
groupId = groupId,
|
||||
name = name,
|
||||
@@ -270,7 +270,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
|
||||
channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null,
|
||||
)
|
||||
} else {
|
||||
account.editRelayGroupMetadata(
|
||||
account.relayGroups.editRelayGroupMetadata(
|
||||
channel = existing,
|
||||
name = name,
|
||||
about = about,
|
||||
|
||||
+1
-1
@@ -567,7 +567,7 @@ open class ChannelNewMessageViewModel :
|
||||
val pk = user.pubkeyHex
|
||||
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
|
||||
try {
|
||||
accountViewModel.account.putRelayGroupUser(channel, pk, emptyList())
|
||||
accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}")
|
||||
|
||||
+2
-2
@@ -509,13 +509,13 @@ private fun SendPaymentLoaded(
|
||||
if (onchainAddressTarget != null) {
|
||||
// Pays the profile's announced bitcoin address directly —
|
||||
// a plain wallet send, no NIP-BC receipt exists for it.
|
||||
accountViewModel.account.sendOnchainToAddress(
|
||||
accountViewModel.account.zaps.sendOnchainToAddress(
|
||||
recipientAddress = onchainAddressTarget,
|
||||
amountSats = amount,
|
||||
feeRateSatPerVByte = feeRate,
|
||||
)
|
||||
} else {
|
||||
accountViewModel.account.sendOnchainZap(
|
||||
accountViewModel.account.zaps.sendOnchainZap(
|
||||
recipientPubKey = user.pubkeyHex,
|
||||
amountSats = amount,
|
||||
feeRateSatPerVByte = feeRate,
|
||||
|
||||
+5
-5
@@ -268,7 +268,7 @@ class SearchBarViewModel(
|
||||
}
|
||||
|
||||
if (term.isBlank()) return@combine emptyList<User>()
|
||||
val users = LocalCache.findUsersStartingWith(term, account)
|
||||
val users = LocalCache.search.findUsersStartingWith(term, account)
|
||||
if (follows != null) users.filter { it.pubkeyHex in follows } else users
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||
@@ -285,7 +285,7 @@ class SearchBarViewModel(
|
||||
) { term, _, currentScope, order, follows ->
|
||||
if (currentScope == SearchScope.PEOPLE) return@combine emptyList()
|
||||
|
||||
val raw = LocalCache.findNotesStartingWith(term, account.hiddenUsers)
|
||||
val raw = LocalCache.search.findNotesStartingWith(term, account.hiddenUsers)
|
||||
val filtered = if (follows != null) raw.filter { it.author?.pubkeyHex in follows } else raw
|
||||
|
||||
when (order) {
|
||||
@@ -317,7 +317,7 @@ class SearchBarViewModel(
|
||||
invalidations,
|
||||
scope,
|
||||
) { term, _, currentScope ->
|
||||
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findPublicChatChannelsStartingWith(term)
|
||||
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findPublicChatChannelsStartingWith(term)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||
|
||||
@@ -327,7 +327,7 @@ class SearchBarViewModel(
|
||||
invalidations,
|
||||
scope,
|
||||
) { term, _, currentScope ->
|
||||
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(term)
|
||||
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findEphemeralChatChannelsStartingWith(term)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||
|
||||
@@ -337,7 +337,7 @@ class SearchBarViewModel(
|
||||
invalidations,
|
||||
scope,
|
||||
) { term, _, currentScope ->
|
||||
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(term)
|
||||
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findLiveActivityChannelsStartingWith(term)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||
|
||||
|
||||
+2
-2
@@ -425,7 +425,7 @@ fun OnchainZapSendDialog(
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
accountViewModel.account.sendOnchainZapWithSplits(
|
||||
accountViewModel.account.zaps.sendOnchainZapWithSplits(
|
||||
recipients = shares,
|
||||
feeRateSatPerVByte = feeRate,
|
||||
comment = comment.trim(),
|
||||
@@ -433,7 +433,7 @@ fun OnchainZapSendDialog(
|
||||
)
|
||||
} else {
|
||||
val recipient = resolvedRecipient ?: return@launch
|
||||
accountViewModel.account.sendOnchainZap(
|
||||
accountViewModel.account.zaps.sendOnchainZap(
|
||||
recipientPubKey = recipient,
|
||||
amountSats = amount,
|
||||
feeRateSatPerVByte = feeRate,
|
||||
|
||||
+1
-1
@@ -347,7 +347,7 @@ class ReloadMintViewModel : ViewModel() {
|
||||
// Fire-and-forget: the mint-quote poll below is the source of truth for
|
||||
// whether the payment actually landed.
|
||||
runCatching {
|
||||
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
|
||||
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
|
||||
}
|
||||
} else {
|
||||
// No NWC — surface the invoice for an external wallet and keep polling.
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ class TopUpMintViewModel : ViewModel() {
|
||||
// Fire-and-forget: the mint-quote poll below is the source of truth for
|
||||
// whether the payment actually landed.
|
||||
runCatching {
|
||||
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
|
||||
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
|
||||
}
|
||||
} else {
|
||||
// No NWC — surface the invoice for an external wallet and keep polling.
|
||||
|
||||
+10
-10
@@ -222,7 +222,7 @@ class WalletViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(NWC_TIMEOUT_MS)
|
||||
val requestId = requestIdProvider()
|
||||
val spoofs = requestId?.let { account?.nwcSpoofAttempts(it) ?: 0 } ?: 0
|
||||
val spoofs = requestId?.let { account?.zaps?.nwcSpoofAttempts(it) ?: 0 } ?: 0
|
||||
_error.value =
|
||||
if (spoofs > 0) {
|
||||
"Wallet request timed out — $spoofs ${if (spoofs == 1) "reply was" else "replies were"} rejected because " +
|
||||
@@ -230,7 +230,7 @@ class WalletViewModel : ViewModel() {
|
||||
} else {
|
||||
"Wallet request timed out"
|
||||
}
|
||||
requestId?.let { account?.cleanupNwcRequest(it) }
|
||||
requestId?.let { account?.zaps?.cleanupNwcRequest(it) }
|
||||
onTimeout()
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ class WalletViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
updateWalletInfo(walletId) { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
|
||||
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
|
||||
when (response) {
|
||||
is GetBalanceSuccessResponse -> {
|
||||
val sats = (response.result?.balance ?: 0L) / 1000L
|
||||
@@ -437,7 +437,7 @@ class WalletViewModel : ViewModel() {
|
||||
val walletUri = getWalletUri(walletId) ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
|
||||
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
|
||||
when (response) {
|
||||
is GetInfoSuccessResponse -> {
|
||||
updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) }
|
||||
@@ -479,7 +479,7 @@ class WalletViewModel : ViewModel() {
|
||||
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
|
||||
try {
|
||||
requestId =
|
||||
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
|
||||
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
|
||||
timeoutJob.cancel()
|
||||
when (response) {
|
||||
is GetBalanceSuccessResponse -> {
|
||||
@@ -512,7 +512,7 @@ class WalletViewModel : ViewModel() {
|
||||
val walletUri = getWalletUri(walletId) ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
|
||||
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
|
||||
when (response) {
|
||||
is GetInfoSuccessResponse -> {
|
||||
_walletAlias.value = response.result?.alias
|
||||
@@ -538,7 +538,7 @@ class WalletViewModel : ViewModel() {
|
||||
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
|
||||
try {
|
||||
requestId =
|
||||
acc.sendNwcRequestToWallet(
|
||||
acc.zaps.sendNwcRequestToWallet(
|
||||
walletUri,
|
||||
ListTransactionsMethod.create(
|
||||
limit = pageSize,
|
||||
@@ -591,7 +591,7 @@ class WalletViewModel : ViewModel() {
|
||||
val timeoutJob = launchTimeout({ requestId }) { _isLoadingMore.value = false }
|
||||
try {
|
||||
requestId =
|
||||
acc.sendNwcRequestToWallet(
|
||||
acc.zaps.sendNwcRequestToWallet(
|
||||
walletUri,
|
||||
ListTransactionsMethod.create(
|
||||
limit = pageSize,
|
||||
@@ -638,7 +638,7 @@ class WalletViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_sendState.value = SendState.Sending
|
||||
try {
|
||||
acc.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
|
||||
acc.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
|
||||
when (response) {
|
||||
is PayInvoiceSuccessResponse -> {
|
||||
_sendState.value = SendState.Success(response.result?.preimage)
|
||||
@@ -676,7 +676,7 @@ class WalletViewModel : ViewModel() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_receiveState.value = ReceiveState.Creating
|
||||
try {
|
||||
acc.sendNwcRequestToWallet(
|
||||
acc.zaps.sendNwcRequestToWallet(
|
||||
walletUri,
|
||||
MakeInvoiceMethod.create(
|
||||
amount = amountSats * 1000L,
|
||||
|
||||
+2
-2
@@ -1467,7 +1467,7 @@ class AmethystAppFunctions {
|
||||
}
|
||||
|
||||
val result =
|
||||
account.sendOnchainZap(
|
||||
account.zaps.sendOnchainZap(
|
||||
recipientPubKey = recipientPub,
|
||||
amountSats = sats,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
@@ -1751,7 +1751,7 @@ class AmethystAppFunctions {
|
||||
val deferred = CompletableDeferred<Response?>()
|
||||
// sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies
|
||||
// (success, error, or NwcError). On timeout we discard the late response.
|
||||
account.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
|
||||
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
|
||||
if (!deferred.isCompleted) deferred.complete(response)
|
||||
}
|
||||
val response =
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Dao
|
||||
import com.vitorpamplona.amethyst.model.LocalCache.getOrCreateAddressableNoteInternal
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.Dao
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
|
||||
Reference in New Issue
Block a user