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:
Vitor Pamplona
2026-08-01 13:31:51 -04:00
committed by GitHub
50 changed files with 4306 additions and 4401 deletions
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
@@ -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,
@@ -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)
}
}
@@ -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(
@@ -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?
}
@@ -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 {
@@ -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(),
)
}
@@ -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
@@ -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,
@@ -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
@@ -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)
}
}
@@ -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
}
@@ -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)
}
},
)
@@ -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()
@@ -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) {
@@ -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) {
@@ -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) }
}
}
@@ -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.
@@ -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()
},
@@ -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 },
@@ -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 },
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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()
@@ -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,
@@ -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}")
@@ -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,
@@ -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())
@@ -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,
@@ -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.
@@ -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.
@@ -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,
@@ -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