diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index acd17cb96f..70afe7bbfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -24,10 +24,6 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.actions.ConcordActions -import com.vitorpamplona.amethyst.commons.actions.ConcordModeration -import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore @@ -37,7 +33,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect -import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -50,10 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache @@ -66,11 +59,6 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent -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.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -163,42 +151,11 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute -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.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot -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.concord.cord02Community.ConcordCommunityListEntry -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent -import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot -import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId -import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity -import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions -import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity -import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity -import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite -import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus -import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary -import com.vitorpamplona.quartz.concord.crypto.GroupKey -import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -226,24 +183,12 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Address -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.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -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.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -274,7 +219,6 @@ import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip13Pow.signer.PoWNostrSigner import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group @@ -295,20 +239,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify 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.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache @@ -316,19 +248,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent -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.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -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.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -345,12 +266,10 @@ import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta @@ -366,8 +285,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent -import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -390,8 +307,6 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log @@ -413,27 +328,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.math.BigDecimal -import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash -private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" - -/** Name of the default Concord community Admin role minted by "Make admin". */ -private const val CONCORD_ADMIN_ROLE = "Admin" - -/** - * How often a joined Concord community's stored invite link is re-resolved to check whether - * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is - * rare and silent, so this trades detection latency for not turning the revision tick into a - * relay-fetch loop. - */ -private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L - @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -802,6 +701,25 @@ class Account( // own chat bubbles. val chatDeliveryTracker = ChatDeliveryTracker(client) + /** Concord community orchestration (join/create/messages/moderation). */ + val concord = AccountConcordActions(this) + + /** Marmot/MLS group orchestration (create/members/admins/messages/key packages). */ + val marmot = AccountMarmotActions(this) + + /** NIP-29 relay-group + Buzz workspace orchestration. */ + val relayGroups = AccountRelayGroupActions(this) + + /** Zap/payment orchestration (NIP-57, NWC, BOLT12, onchain). */ + val zaps = AccountZapActions(this) + + /** + * Relay routing + sign-and-publish choke point: computes which relays an event + * should go to (outbox model, hints, channels, broadcast lists) and owns every + * publish path. All Account send helpers delegate here. + */ + val broadcaster = EventBroadcaster(this) + val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) } @@ -1408,278 +1326,6 @@ class Account( cache.justConsumeMyOwnEvent(event) } - suspend fun createZapRequestFor( - event: Event, - pollOption: Int?, - message: String = "", - zapType: LnZapEvent.ZapType, - toUser: User?, - additionalRelays: Set? = null, - amountMillisats: Long? = null, - lnurl: String? = null, - ) = LnZapRequestEvent.create( - zappedEvent = event, - relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), - signer = signer, - pollOption = pollOption, - message = message, - zapType = zapType, - toUserPubHex = toUser?.pubkeyHex, - amountMillisats = amountMillisats, - lnurl = lnurl, - ) - - suspend fun calculateIfNoteWasZappedByAccount( - zappedNote: Note?, - afterTimeInSeconds: Long, - ): Boolean = zappedNote?.isZappedBy(userProfile(), afterTimeInSeconds, this) == true - - suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) - - suspend fun sendNwcRequest( - request: Request, - onResponse: (Response?) -> Unit, - ) { - val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse) - client.publish(event, setOf(relay)) - } - - suspend fun sendNwcRequestToWallet( - walletUri: Nip47WalletConnect.Nip47URINorm, - request: Request, - onResponse: (Response?) -> Unit, - ): HexKey { - val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse) - 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) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) - 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 = nip47SignerState.defaultWalletUri.value ?: return false - return 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 -> - 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 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 -> - 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 (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) { - cache.justConsumeMyOwnEvent(zap) - client.publish(zap, 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 = nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()), - signer = signer, - message = message, - zapType = zapType, - amountMillisats = amountMillisats, - lnurl = lnurl, - ) - - 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? = null, - ): OnchainZapSendResult { - val backend = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.send( - backend = backend, - signer = signer, - senderPubKey = signer.pubKey, - recipientPubKey = recipientPubKey, - amountSats = amountSats, - feeRateSatPerVByte = feeRateSatPerVByte, - comment = comment, - zappedEvent = zappedEvent, - ) { template -> 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 = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.sendToAddress( - backend = backend, - signer = signer, - senderPubKey = 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, - feeRateSatPerVByte: Double, - comment: String = "", - zappedEvent: EventHintBundle? = null, - ): OnchainZapSendResult { - val backend = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.sendSplit( - backend = backend, - signer = signer, - senderPubKey = signer.pubKey, - recipients = recipients, - feeRateSatPerVByte = feeRateSatPerVByte, - comment = comment, - zappedEvent = zappedEvent, - ) { template -> signAndComputeBroadcast(template) } - } - suspend fun report( note: Note, type: ReportType, @@ -1929,239 +1575,57 @@ class Account( relaysItCameFrom } - private fun computeRelayListForLinkedUser(user: User): Set = - if (user == userProfile()) { - notificationRelays.flow.value - } else { - user.inboxRelays()?.ifEmpty { null }?.toSet() - ?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.allUsedRelays()) - } + // ------------------------------------------------------------------ + // Broadcast / relay-routing delegates (logic lives in EventBroadcaster). + // ------------------------------------------------------------------ - private fun computeRelayListForLinkedUser(pubkey: HexKey): Set = - if (pubkey == userProfile().pubkeyHex) { - notificationRelays.flow.value - } else { - cache - .getUserIfExists(pubkey) - ?.inboxRelays() - ?.ifEmpty { null } - ?.toSet() - ?: cache.relayHints.hintsForKey(pubkey).toSet() - } + fun computeRelayListToBroadcast(event: Event): Set = broadcaster.computeRelayListToBroadcast(event) - private fun computeRelaysForChannels(event: Event): Set = cache.getAnyChannel(event)?.relays() ?: emptySet() + fun computeRelayListToBroadcast(note: Note): Set = broadcaster.computeRelayListToBroadcast(note) - // 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 + suspend fun broadcast(note: Note) = broadcaster.broadcast(note) - val channelRelays = cache.getAnyChannel(event)?.relays() - if (channelRelays != null && channelRelays.isNotEmpty()) return false + fun sendAutomatic(events: List) = broadcaster.sendAutomatic(events) - return true - } + fun sendAutomatic(event: Event?) = broadcaster.sendAutomatic(event) - fun computeRelayListToBroadcast(event: Event): Set = computeRelayListToBroadcast(event, mutableSetOf()) + fun sendMyPublicAndPrivateOutbox(event: Event?) = broadcaster.sendMyPublicAndPrivateOutbox(event) - private fun computeRelayListToBroadcast( - event: Event, - visited: MutableSet, - ): Set { - // a-tagged events can form cycles; without this the two recursive descents stack-overflow. - if (!visited.add(event.id)) return emptySet() + fun sendMyPublicAndPrivateOutbox(events: List) = broadcaster.sendMyPublicAndPrivateOutbox(events) - if (event is GiftWrapEvent) { - val receiver = event.recipientPubKey() - return if (receiver != null) { - val relayList = - 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() - } + fun sendLiterallyEverywhere(event: Event) = broadcaster.sendLiterallyEverywhere(event) - val includeBroadcast = wantsBroadcastRelays(event) - val broadcastRelays = if (includeBroadcast) broadcastRelayList.flow.value else emptySet() + suspend fun signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) = broadcaster.signAndSendPrivately(template, relayList) - if (event is MetadataEvent || event is AdvertisedRelayListEvent) { - // everywhere - return followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value + broadcastRelays - } + suspend fun signWithAndSendPrivately( + template: EventTemplate, + signer: NostrSigner, + relayList: Set, + ): T = broadcaster.signWithAndSendPrivately(template, signer, relayList) - val relayList = mutableSetOf() - relayList.addAll(broadcastRelays) + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): T = broadcaster.signAndSendPrivatelyOrBroadcast(template, relayList) - val author = cache.getUserIfExists(event.pubKey) + suspend fun signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T = broadcaster.signAndComputeBroadcast(template, broadcast) - if (author != null) { - if (author == userProfile()) { - if (includeBroadcast) { - relayList.addAll(outboxRelays.flow.value) - } else { - // outboxRelays mixes in the broadcast list; for personal/channel events - // we want the user's NIP-65 / private / local outbox without it. - relayList.addAll(nip65RelayList.outboxFlow.value) - relayList.addAll(privateStorageRelayList.flow.value) - relayList.addAll(localRelayList.flow.value) - } - } else { - val relays = - author.outboxRelays()?.ifEmpty { null } - ?: author.allUsedRelaysOrNull() - ?: cache.relayHints.hintsForKey(author.pubkeyHex) + suspend fun signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), + ): T = broadcaster.signAnonymouslyAndBroadcast(template, broadcast, anonymousSigner) - relayList.addAll(relays) - } - } else { - relayList.addAll(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 -> - 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 -> - 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 { - 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 { 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(pubKey)), - ids = listOf(host.id), - ) - } - client - .fetchFirst( - filters = relays.associateWith { _ -> listOf(filter) }, - )?.let { downloadedEvent -> - val toRelays = computeRelayListToBroadcast(downloadedEvent) - 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 { - client.publish(noteEvent, computeRelayListToBroadcast(note)) - } - } - } + fun republishEventsTo( + events: List, + relays: Set, + ) = broadcaster.republishEventsTo(events, relays) fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic) @@ -2183,245 +1647,6 @@ class Account( suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel)) - /** - * Add a joined Concord community (secret-bearing entry) to the private kind-13302 - * list, and announce a self-signed Guestbook JOIN so this member is visible to - * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). - */ - suspend fun joinConcordCommunity( - entry: ConcordCommunityListEntry, - inviteCreator: HexKey? = null, - inviteLabel: String? = null, - ) { - sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) - announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) - } - - /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ - private suspend fun announceConcordGuestbookJoin( - entry: ConcordCommunityListEntry, - inviteCreator: HexKey?, - inviteLabel: String?, - ) { - if (!isWriteable()) return - val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildGuestbookJoin(signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) - concordSessions.ingest(wrap) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** - * Create a new Concord community: mint its genesis (metadata + #general), - * publish the owner-signed genesis wraps to [relays] (or our outbox), and add - * the secret-bearing entry to the kind-13302 joined list. Returns the new - * community id, or null if not writeable. - */ - suspend fun createConcordCommunity( - name: String, - description: String? = null, - relays: List = emptyList(), - icon: ImagePointer? = null, - ): String? { - if (!isWriteable()) return null - val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } - val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls, icon) - - val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } - community.genesisWraps.forEach { client.publish(it, publishTo) } - - joinConcordCommunity( - ConcordCommunityListEntry( - id = community.communityIdHex, - owner = community.ownerPubKey, - ownerSalt = community.ownerSalt.toHexKey(), - root = community.communityRoot.toHexKey(), - rootEpoch = community.rootEpoch, - relays = relayUrls, - name = name, - addedAt = TimeUtils.now() * 1000, - ), - ) - return community.communityIdHex - } - - /** - * Mint a shareable invite link for a joined community and publish its - * kind-33301 public bundle to the community relays. Returns the `…/invite/…` - * URL, or null if the community isn't joined or isn't writeable. - */ - suspend fun mintConcordInvite( - communityId: String, - base: String = "https://amethyst.social", - ): String? { - if (!isWriteable()) return null - val entry = concordChannelList.liveCommunities.value.firstOrNull { it.id == communityId } ?: return null - val invite = - ConcordActions.inviteFor( - communityIdHex = entry.id, - ownerPubKey = entry.owner, - ownerSaltHex = entry.ownerSalt, - communityRootHex = entry.root, - rootEpoch = entry.rootEpoch, - name = entry.name, - relays = entry.relays, - ) - val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) - - val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } - if (publishTo.isNotEmpty()) client.publish(minted.bundleEvent, publishTo) - return minted.url - } - - /** Drop a joined Concord community from the private kind-13302 list by its id. */ - suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId)) - - /** - * Redeem a Concord invite link (`…/invite/#`): parse it, fetch - * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it - * with the fragment token, and add the resulting secret-bearing entry to the - * kind-13302 joined list. - * - * Returns a [ConcordInviteResult] that separates the failure modes so the UI can - * both explain what went wrong and decide whether a retry could ever help — a - * bundle we can't open (e.g. minted by a newer client) must not strand the user - * on a spinner that retries forever. - * - * A bundle whose `expires_at` has passed is rejected with - * [ConcordInviteResult.Expired]. Expiry is resolved inside - * [ConcordActions.classifyInvite], so it is enforced on every redeem path rather - * than being a field nobody reads. - * - * **This must only ever be called from an explicit user action.** It contacts - * relay URLs carried in the link (chosen by whoever minted it) and publishes a - * Guestbook JOIN signed by this account, so calling it on deep-link arrival would - * leak the user's IP and enroll them without consent — see `ConcordInviteScreen`. - * - * If the resolved community is already in the joined list, this returns - * [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook - * JOIN, so reopening an old invite for a community you're already in simply takes - * you to it. - */ - suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { - if (!isWriteable()) return ConcordInviteResult.InvalidLink - val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink - - val relays = - (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return ConcordInviteResult.NotReachable - - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - - // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a - // stale openable copy) so we honour revocation and can tell the user *why* a link won't open - // instead of stranding them on a spinner that retries a link we can never redeem. - val bundle = - when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { - is InviteBundleStatus.Live -> status.invite - is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired - InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked - InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible - InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable - } - - // Already a member? Just take the user to the community. Re-following and re-announcing a - // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an - // old invite is reopened, so short-circuit to Joined — the screen forwards to the community - // either way ("take me there", not "join again"). - if (concordChannelList.liveCommunities.value.any { it.id == bundle.communityId }) { - return ConcordInviteResult.Joined(bundle.communityId) - } - - val entry = - ConcordCommunityListEntry( - id = bundle.communityId, - owner = bundle.owner, - ownerSalt = bundle.ownerSalt, - root = bundle.communityRoot, - rootEpoch = bundle.rootEpoch, - relays = bundle.relays, - name = bundle.name, - addedAt = TimeUtils.now() * 1000, - // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a - // Refounding that leaves us out of the recipient set is recoverable later. See - // recoverStrandedConcordCommunities(). - inviteRef = ConcordActions.bareInviteRef(url), - ) - joinConcordCommunity(entry) - return ConcordInviteResult.Joined(bundle.communityId) - } - - /** - * Post [text] to a Concord channel: derive the channel plane key, build an - * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), - * fold it locally for an instant echo, and publish it to the community's relays. - * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes - * straight to the community relay set. Returns false if not writeable or the - * community isn't currently joined/folded. - */ - suspend fun sendConcordChannelMessage( - communityId: String, - channelIdHex: String, - text: String, - replyTo: Note? = null, - replyMode: ReplyMode = ReplyMode.INLINE, - imetas: List = emptyList(), - ): Boolean { - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - - // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the - // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). - val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() - - val parent = replyTo?.event - val wrap = - when { - // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when - // the user attached media); an inline reply is a kind-9 message quoting the parent; a - // fresh post is a plain kind-9 message. - parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> - ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) - parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) - parent != null -> - ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) - else -> - ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) - } - trackConcordDelivery(entry, channelKey, wrap) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Send a channel message carrying encrypted image attachments ([imetas], built by the composer - * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are - * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no - * attachments this is just a plain [sendConcordChannelMessage]. - */ - suspend fun sendConcordChannelImageMessage( - communityId: String, - channelIdHex: String, - text: String, - imetas: List, - ): Boolean { - if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. - val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) - trackConcordDelivery(entry, channelKey, wrap) - publishConcordWrap(entry, wrap) - return true - } - /** * Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that * message. Resolves the chat context from the note's gatherer; today it drives the @@ -2437,7 +1662,7 @@ class Account( val gatherers = rootNote.inGatherers gatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { concord -> - return sendConcordChannelMessage( + return this.concord.sendConcordChannelMessage( concord.channelId.communityId, concord.channelId.channelId, text, @@ -2536,1182 +1761,6 @@ class Account( return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") } - /** - * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors - * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's - * channel/epoch, wraps it on the plane, and publishes it — so the reaction stays - * inside the encrypted channel (never a plaintext public kind-7 that would leak - * the message id). [note] must be a Concord channel message (carries a - * [ConcordChannel] gatherer). - */ - suspend fun reactToConcordMessage( - note: Note, - reaction: String, - ): Boolean { - if (!isWriteable()) return false - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false - val target = note.event ?: return false - val communityId = channel.channelId.communityId - val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to - // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. - val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Edit my own Concord channel message [note] to [newText]. Mirrors - * [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the - * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays - * inside the encrypted channel (a public edit would e-tag the private rumor id onto - * public relays). The receiving side overlays the newest edit onto the target message; - * only the *original author's* edits are applied, so we gate to my own kind-9 messages. - * Returns false if [note] isn't an editable Concord message I authored. - */ - suspend fun editConcordChannelMessage( - note: Note, - newText: String, - ): Boolean { - if (!isWriteable()) return false - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false - val target = note.event ?: return false - // Edits only apply to plain kind-9 messages, and only the author may edit their own. - if (target !is ChatEvent || target.pubKey != signer.pubKey) return false - - val communityId = channel.channelId.communityId - val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. - val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at - * most every few seconds while composing. Not folded locally (we never show our own typing); - * ephemeral, so relays broadcast but never store it. - */ - suspend fun sendConcordTyping( - communityId: String, - channelIdHex: String, - ) { - if (!isWriteable()) return - val entry = concordSessions.sessionFor(communityId)?.entry ?: return - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ - private fun publishConcordWrap( - entry: ConcordCommunityListEntry, - wrap: Event, - ) { - concordSessions.ingest(wrap) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** - * Registers an own Concord channel message with the delivery tracker so its chat - * bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed - * shows the inner rumor, so we re-open the wrap (we just built it, so this always - * succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions - * and typing wraps skip this — they never become a feed row. - */ - private fun trackConcordDelivery( - entry: ConcordCommunityListEntry, - channelKey: GroupKey, - wrap: Event, - ) { - val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays) - } - - // ── Concord roles & moderation (CORD-04) ───────────────────────────────── - // Each publishes a Control Plane edition; authority is enforced at fold time by - // every client's AuthorityResolver, so a call by someone who doesn't outrank the - // target is simply dropped on fold. Owner-authored calls always take effect. - - /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ - suspend fun grantConcordRole( - communityId: String, - member: HexKey, - roleIds: List, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** The default community Admin role: position 1, holding every management + moderation permission. */ - private fun concordAdminRole() = - RoleEntity( - name = CONCORD_ADMIN_ROLE, - position = 1, - permissions = - ConcordPermissions - .of( - ConcordPermissions.MANAGE_ROLES, - ConcordPermissions.MANAGE_CHANNELS, - ConcordPermissions.MANAGE_METADATA, - ConcordPermissions.KICK, - ConcordPermissions.BAN, - ConcordPermissions.MANAGE_MESSAGES, - ConcordPermissions.CREATE_INVITE, - ).toWire(), - ) - - /** - * If [note] is a Concord channel message whose author the OWNER may toggle - * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner - * qualifies — the Admin role sits at position 1 and the resolver requires the - * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for - * the owner's own note, the owner as target, or a non-owner actor. - */ - fun concordAdminTarget(note: Note): Triple? { - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null - val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null - if (author == signer.pubKey) return null - val communityId = channel.channelId.communityId - val state = concordSessions.sessionFor(communityId)?.state?.value ?: return null - if (state.authority.isOwner(author) || !state.authority.isOwner(signer.pubKey)) return null - val adminRoleId = - state.roles.entries - .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } - ?.key - val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) - return Triple(communityId, author, isAdmin) - } - - /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ - suspend fun makeConcordAdmin( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val cp = session.controlPlaneKey() - - val existing = - session.state.value - ?.roles - ?.entries - ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } - val roleIdHex = - existing?.key ?: run { - val roleId = RandomInstance.bytes(32) - val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, roleWrap) - roleId.toHexKey() - } - - val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, grantWrap) - return true - } - - /** Revoke all roles from [member] (demote an admin back to a plain member). */ - suspend fun removeConcordAdmin( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, grantWrap) - return true - } - - /** - * If [note] is a Concord channel message whose author this account is allowed to - * ban — the actor outranks the target and holds the BAN permission, and the target - * is neither the owner nor the actor — returns `(communityId, memberHex)`. Null - * otherwise, so the UI offers Ban only where we are willing to act. - * - * The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the - * BANLIST is a single whole-list entity, so neither this client's fold nor Armada's - * rank-checks the *contents* of a banlist edition — both gate only on the author's - * BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its - * role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin - * above them is therefore *accepted* by every client today. Since we cannot refuse - * such a ban without diverging from Armada, we at least refuse to author one — this - * restricts what we write, never what we accept, so it cannot split consensus. - * Enforcing it on the fold needs a spec change; see the QA plan's open findings. - */ - fun concordBanTarget(note: Note): Pair? { - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null - val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null - if (author == signer.pubKey) return null - val communityId = channel.channelId.communityId - val authority = - concordSessions - .sessionFor(communityId) - ?.state - ?.value - ?.authority ?: return null - if (authority.isOwner(author)) return null - // The owner short-circuits rather than going through canActOn: canActOn starts at - // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put - // the owner on the banlist (see the KDoc) — routing the owner through it would let them be - // locked out of moderating their own community. - val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN) - return if (canBan) communityId to author else null - } - - /** Add [member] to the community banlist. */ - suspend fun banConcordMember( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Remove [member] from the community banlist. */ - suspend fun unbanConcordMember( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── - // A ban is a soft removal — the banned member still holds the room key and can - // still decrypt traffic; every client just declines to *show* their posts. A - // Refounding is the hard removal: it rotates the community_root, so a removed - // member's key stops working for anything published afterwards. - - /** - * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, - * roll the `community_root`, re-key every retained member (Guestbook membership ∪ - * observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted - * Control Plane under the new root. A removed member keeps the prior root (so - * their history stays readable) but receives no blob, so they can never decrypt - * anything published after the rotation. - * - * Requires ownership or the BAN permission; returns false otherwise (or if the - * community isn't joined/writeable, or a target is the owner). - */ - suspend fun refoundConcordCommunity( - communityId: String, - removed: Set, - ): Boolean { - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val state = session.state.value ?: return false - val authority = state.authority - val iCanBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) - if (!iCanBan) return false - val removedLower = removed.mapTo(HashSet()) { it.lowercase() } - if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false - - // 1. Ban the removed members on the current Control Plane so the compacted snapshot — - // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally - // first, so each subsequent edition chains onto the updated banlist head. - for (target in removedLower) { - val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, banWrap) - } - - // 2. Recipient set: everyone we're keeping, minus the removed and the already-banned. - // Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the - // Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other - // clients need not), so building the set without observed authors silently expelled every - // member who had only ever posted: they hold no role, receive no blob, and the Refounding - // strands them. That mainly hit cross-client communities, where Armada members are the - // bulk of the roster. - // - // Still a floor, not a census (see allMembers): a member who joined without a Guestbook - // motion, holds no role, and has never posted leaves no trace to find, so a Refounding - // cannot re-key them. Stranded recovery is what gets those members back. - val recipients = - (session.allMembers() + signer.pubKey) - .mapTo(HashSet()) { it.lowercase() } - .apply { - removeAll(removedLower) - removeAll(authority.bannedMembers()) - }.toList() - - // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. - val entry = session.entry - val newRoot = RandomInstance.bytes(32) - val build = - ConcordActions.buildRefounding( - rotatorSigner = signer, - communityId = communityId, - priorRoot = entry.root.hexToByteArray(), - newRoot = newRoot, - rootEpoch = entry.rootEpoch, - priorControlWraps = session.controlPlaneWraps(), - priorControlKey = session.controlPlaneKey(), - recipientsXOnly = recipients, - createdAt = TimeUtils.now(), - ) - - // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs - // (the key that unlocks it) to the community relays. - val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (publishTo.isNotEmpty()) { - build.controlWraps.forEach { client.publish(it, publishTo) } - build.rekeyWraps.forEach { client.publish(it, publishTo) } - } - - // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and - // re-folds the compacted Control Plane (with the ban), dropping the removed members. - adoptConcordRoot(entry, newRoot, build.newEpoch) - return true - } - - // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered - // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not - // adopted — and re-published — twice on successive revision ticks. - private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) - - /** - * Persist a rotated access root/epoch for [entry], keeping the prior root as a - * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the - * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — - * without this, cascading removals would lose everyone but the roster). No-op if - * this exact rotation was already adopted. - */ - private suspend fun adoptConcordRoot( - entry: ConcordCommunityListEntry, - newRoot: ByteArray, - newEpoch: Long, - ) { - if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } - val next = - ConcordCommunityListEntry( - id = entry.id, - owner = entry.owner, - ownerSalt = entry.ownerSalt, - root = newRoot.toHexKey(), - rootEpoch = newEpoch, - heldRoots = held, - privateChannels = entry.privateChannels, - relays = entry.relays, - name = entry.name, - addedAt = entry.addedAt, - // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left - // out of would be unrecoverable. - inviteRef = entry.inviteRef, - excludedAtEpoch = entry.excludedAtEpoch, - // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) - // must survive our rotation write, or we delete their data on every rekey. - residue = entry.residue, - ) - sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) - announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) - } - - /** - * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for - * each joined community, look for our new root among the kind-3303 wraps seen at - * our next base-rekey address. If a role-authorized rotator (owner or a current, - * non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the - * session rebuilds at the new epoch and its next-rekey address moves on, so a stale - * wrap never re-triggers. Called on every Concord revision tick. - * - * Authority is the roster, never key possession: any non-banned BAN-holder may - * rotate, including for the owner. The owner deliberately does NOT refuse a root - * authored by someone else — refusing would strand the owner alone on the dead - * epoch whenever an admin legitimately rotates, and would diverge from Armada, - * which forks a community across clients. Self-escalation to BAN is prevented - * upstream by the role rank gate in AuthorityResolver. - * - * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, - * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the - * owner included) by omission — nothing on this receive path can prevent it. The - * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves - * the invite link the membership was joined through and merges forward. - */ - private suspend fun drainConcordRekeys() { - if (!isWriteable()) return - for (session in concordSessions.sessions()) { - val wraps = session.pendingBaseRekeyWraps() - if (wraps.isEmpty()) continue - val entry = session.entry - val received = - ConcordActions.openBaseRekey( - wraps = wraps, - baseRekey = session.nextBaseRekeyKey(), - recipientSigner = signer, - priorRoot = entry.root.hexToByteArray(), - rootEpoch = entry.rootEpoch, - ) ?: continue - if (received.newEpoch <= entry.rootEpoch) continue - val authority = session.state.value?.authority ?: continue - - // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder - // who has themselves been banned could still rotate the whole community. - val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) - if (!authorized) continue - adoptConcordRoot(entry, received.newRoot, received.newEpoch) - } - } - - // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the - // Concord revision tick (which fires on every structural change) without turning it into a - // relay-fetch loop. - private val lastConcordRecoveryCheck = ConcurrentHashMap() - - /** - * Stranded recovery (CORD-05/06 receive path). A Refounding carries only - * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left - * out of the rekey recipient set receives nothing and sits on the dead epoch - * forever while everyone else moves on. This happens to any member, the owner - * included, and [drainConcordRekeys] cannot prevent it: there is no message to - * miss detecting. - * - * The way back is the invite link the membership was joined through - * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and - * carried through every rotation by [adoptConcordRoot]). The community keeps - * re-minting its bundle at that same addressable coordinate, so a bundle there at - * a **strictly higher** epoch than ours proves we were left behind — and carries - * the new root. Same or lower epoch is a no-op. Memberships with no link (direct - * invites, legacy entries) are inert here; that is expected, not an error. - * - * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps - * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the - * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays - * derivable). We then re-announce the Guestbook at the new epoch, exactly as an - * ordinary rotation does, so the recovered member is visible to whoever refounds - * next instead of being silently dropped again. - * - * Called on the Concord revision tick, but rate-limited per community - * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. - */ - private suspend fun recoverStrandedConcordCommunities() { - if (!isWriteable()) return - val now = TimeUtils.nowMillis() - for (entry in concordChannelList.liveCommunities.value) { - val inviteRef = entry.inviteRef ?: continue - val last = lastConcordRecoveryCheck[entry.id] - if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue - lastConcordRecoveryCheck[entry.id] = now - - val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue - val relays = - ( - parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + - entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - ).toSet() - if (relays.isEmpty()) continue - - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. - val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue - - val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue - if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue - Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") - sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged)) - announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) - } - } - - /** - * Replace the community metadata (name / icon / description / relays) with a new - * Control-Plane edition. Honored on fold only when this account holds - * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. - */ - suspend fun editConcordMetadata( - communityId: String, - name: String, - description: String?, - icon: ImagePointer?, - banner: ImagePointer?, - relays: List, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) - val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** - * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold - * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on - * the same predicate. The channel id is a fresh random 32-byte entity id. - */ - suspend fun createConcordChannel( - communityId: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val channelId = RandomInstance.bytes(32) - val channel = ChannelEntity(name = name.trim()) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ - suspend fun renameConcordChannel( - communityId: String, - channelIdHex: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - // Carry the standing definition forward and change only the name. A ChannelEntity built from - // scratch defaults `private` and `voice` to false, so renaming a private channel used to - // publish an edition declaring it PUBLIC — and a voice channel became a text channel. - val standing = - session.state.value - ?.channels - ?.get(channelIdHex) - ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ - suspend fun deleteConcordChannel( - communityId: String, - channelIdHex: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - // Same as rename: preserve the standing flags so a tombstone does not also silently - // reclassify the channel it retires. - val standing = - session.state.value - ?.channels - ?.get(channelIdHex) - ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** - * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from - * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT - * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a - * card can show what the link opens, or null if the link is invalid/unreadable. - */ - suspend fun peekConcordInvite(url: String): CommunityInvite? { - val parsed = ConcordActions.parseInviteLink(url) ?: return null - val relays = - (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return null - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } - } - - /** - * Bootstrap the Concord hub from the network: fetch this account's kind-13302 - * joined-communities list and fold the newest into [LocalCache], so communities - * we joined on another Concord client with this key surface here. - * - * We query a wide relay set because different Concord clients publish this - * private list to different places: the reference clients (Armada/Vector) push - * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may - * also have copied it onto their **own** outbox/read relays. Our normal account - * subscription never asks for kind 13302, so without this explicit fetch a - * community joined on Armada would never appear — even if the list sits on the - * user's own outbox. - * - * Read-only import: kind 13302 is replaceable, so folding an older copy is a - * no-op and this is safe to call on every hub open. Merging our own edits with - * a foreign writer's is a separate concern (newest-wins replaceable). - * - * [extraRelays] are additional relays to query — the bootstrap relays saved on the - * bottom-bar tabs of pinned communities. A community's private list frequently lives - * only on the community's own relays (never the user's outbox), so a community pinned - * to the bottom bar would otherwise never surface when opened cold. - */ - suspend fun importConcordCommunities(extraRelays: Set = emptySet()) { - val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value + extraRelays).toSet() - if (relays.isEmpty()) return - val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) - // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give - // the fetch a generous window to drain every relay before we pick the newest copy. - val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) - val newest = events.filterIsInstance().maxByOrNull { it.createdAt } - val entryCount = newest?.let { runCatching { it.decrypt(signer).size }.getOrElse { -1 } } ?: 0 - Log.d( - "Concord", - "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + - "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", - ) - newest?.let { cache.justConsumeMyOwnEvent(it) } - } - - /** - * One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox - * fill in without the user opening each channel one by one. Per channel, a channel read before is - * caught up from its last-read time (accurate unread badge + the missed messages ready when it - * opens) while a channel never read pulls only its single newest wrap for a preview — see - * [ConcordSubscriptionPlanner.channelPreviewFilters]. - * - * This is deliberately **not** a live subscription: every wrap the drain pulls flows through the - * global cache connector (`CacheClientConnector` → `LocalCache.justConsume` → `concordSessions.ingest`), - * so it lands in the channel's message store the previews/unread counts read — and the always-on - * plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward. - * So this only needs to run when a community's channels first fold (the account preload) or its - * screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay. - */ - suspend fun warmConcordChannelPreviews(entries: List) { - val filters = - entries.flatMap { entry -> - val state = concordSessions.sessionFor(entry.id)?.state?.value ?: return@flatMap emptyList() - ConcordSubscriptionPlanner.channelPreviewFilters( - entry, - state, - lastReadFor = { channelIdHex -> - loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) - }, - accountPubKey = userProfile().pubkeyHex, - ) - } - if (filters.isEmpty()) return - val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } - client.fetchAll(filters = byRelay, timeoutMs = 20_000L) - } - - /** - * COMPLETE-mode Control-Plane sync — Armada's plane-sweep discipline for the one plane that must - * never fold on a truncated edition set. - * - * The Control Plane defines the channel list, the roster and the banlist, so a *partial* fold - * silently drops channels or mis-renders membership. Two ways that happens, both closed here: - * - **Forward-cursor gap:** the live plane subscription advances a `since` cursor, so an edition - * with a `created_at` below the high-water mark that we never actually ingested — an unban - * published while we were offline, a CORD-06 compaction re-wrap under a newly-held epoch — is - * never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the - * whole plane every run. - * - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can - * crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until` - * cursors until a plane is drained), so the fold sees every edition regardless of the cap. - * - * Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the - * priors). Wraps ingest through the global cache connector → [concordSessions] like every other - * Concord drain; AUTH is the shared stream-key handler. Merging communities that share a relay into - * one filter is safe here precisely because we page — the cap no longer truncates. The live control - * subscription still carries brand-new editions in real time; this is the periodic completeness pass. - */ - suspend fun syncConcordControlPlanes(entries: List) { - if (entries.isEmpty()) return - val authorsByRelay = HashMap>() - for (entry in entries) { - for (sub in ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))) { - for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) - } - } - if (authorsByRelay.isEmpty()) return - // No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a - // plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap. - val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) } - var drained = 0 - client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } - Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)") - } - - // ── NIP-29 relay-group actions ─────────────────────────────────────────── - // 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - 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 (!isWriteable()) return - val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) - 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, - ): String? { - val signed = 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 = 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) }) { - client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))), - timeoutMs = 8_000, - pendingOnAuthRequired = true, - ) { _, _ -> false } - results = 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): 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) - 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) - 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 (!isWriteable()) return null - val signed = signer.sign(JobRequestEvent.build(request, channelId, null)) - // Reflect it locally so the board updates immediately (publish only sends to relays). - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return - val signed = signer.sign(JobCancelEvent.build(jobId, "", channelId)) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId)) - val signed = signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) }) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val workflowId = RandomInstance.randomChars(16) - val signed = signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val signed = signer.sign(ApprovalGrantEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return null - val signed = signer.sign(ApprovalDenyEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - 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 (!isWriteable()) return - val template = - eventTemplate(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 = signer.sign(template) - cache.justConsumeMyOwnEvent(signed) - 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - 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 = emptyList(), - geohashes: List = 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. - 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, - ) - signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } - - val id = GroupId(groupId, relay) - 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 = - 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(pubKey)) - } - 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) - 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, - ) { - val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) - 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)) - 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, - ) { - // 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) - 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, - ) { - 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, - ) { - 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 = emptyList(), - geohashes: List = emptyList(), - parent: String? = channel.parentGroupId(), - children: List = 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, - ) - 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) @@ -3748,14 +1797,6 @@ class Account( client.publish(signedEvent, relays) } - fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } - - fun sendAutomatic(event: Event?) { - if (event == null) return - cache.justConsumeMyOwnEvent(event) - client.publish(event, computeRelayListToBroadcast(event)) - } - suspend fun sendWebBookmark( url: String, title: String?, @@ -3974,24 +2015,6 @@ class Account( client.publish(signedEvent, outboxRelays.flow.value) } - fun sendMyPublicAndPrivateOutbox(event: Event?) { - if (event == null) return - cache.justConsumeMyOwnEvent(event) - client.publish(event, outboxRelays.flow.value) - } - - fun sendMyPublicAndPrivateOutbox(events: List) { - events.forEach { - client.publish(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } - } - - fun sendLiterallyEverywhere(event: Event) { - client.publish(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value) - cache.justConsumeMyOwnEvent(event) - } - suspend fun pollRespond( event: PollEvent, responses: Set, @@ -4224,96 +2247,6 @@ class Account( signAndComputeBroadcast(template) } - suspend fun signAndSendPrivately( - template: EventTemplate, - relayList: Set, - ) { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - 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 signWithAndSendPrivately( - template: EventTemplate, - signer: NostrSigner, - relayList: Set, - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - if (relayList.isNotEmpty()) client.publish(event, relayList) - return event - } - - suspend fun signAndSendPrivatelyOrBroadcast( - template: EventTemplate, - relayList: (T) -> List?, - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - val relays = relayList(event) - val targets = - if (!relays.isNullOrEmpty()) { - relays.toSet() - } else { - computeRelayListToBroadcast(event) - } - chatDeliveryTracker.trackPublic(event.id, targets) - client.publish(event, targets) - return event - } - - suspend fun signAndComputeBroadcast( - template: EventTemplate, - broadcast: List = emptyList(), - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - val note = - if (event is AddressableEvent) { - cache.getOrCreateAddressableNote(event.address()) - } else { - cache.getOrCreateNote(event.id) - } - - val relayList = computeRelayListToBroadcast(note) - - client.publish(event, relayList) - - broadcast.forEach { client.publish(it, relayList) } - - return event - } - - suspend fun signAnonymouslyAndBroadcast( - template: EventTemplate, - broadcast: List = emptyList(), - anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), - ): T { - val event = anonymousSigner.sign(template) - - cache.justConsumeMyOwnEvent(event) - val note = - if (event is AddressableEvent) { - cache.getOrCreateAddressableNote(event.address()) - } else { - cache.getOrCreateNote(event.id) - } - - val relayList = computeRelayListToBroadcast(note) - - client.publish(event, relayList) - - broadcast.forEach { client.publish(it, relayList) } - - return event - } - /** * Creates a post event without sending it. * Returns the event, target relays, and extra events to broadcast. @@ -4806,541 +2739,6 @@ class Account( // --- Marmot Group Messaging --- - /** - * 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 { - val groupRelays = - marmotManager - ?.groupMetadata(nostrGroupId) - ?.relays - ?.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer - .normalizeOrNull(it) - }?.toSet() - return if (!groupRelays.isNullOrEmpty()) groupRelays else 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, - ) { - 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 = marmotManager ?: return - if (!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 - 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. - marmotGroupList.markAsKnown(nostrGroupId) - if (groupRelays.isEmpty()) { - Log.w("MarmotDbg") { - "sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped" - } - } - 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 = marmotManager ?: return "Error: Marmot not initialized" - if (!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 = outboxRelays.flow.value - val memberKeyPackageRelays = - ( - cache - .getAddressableNoteIfExists( - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent - .createAddress(memberPubKey), - )?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent - )?.relays()?.toSet().orEmpty() - val memberOutbox = - 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(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, - ) { - val memberPubKey = keyPackageEvent.pubKey - Log.d("MarmotDbg") { - "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " + - "groupRelays=${groupRelays.size}" - } - val manager = marmotManager ?: return - if (!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 = 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 }}" - } - 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 = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) - val recipientInbox = - cache - .getOrCreateUser(memberPubKey) - .dmInboxRelays() - .orEmpty() - val relayList = computed + outboxRelays.flow.value + recipientInbox - Log.d("MarmotDbg") { - "addMarmotGroupMember: welcome gift wrap relay sources " + - "computeRelayListToBroadcast=${computed.size} myOutbox=${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 }}" - } - } - 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 = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value) - - /** - * Publish or rotate KeyPackage events. - */ - suspend fun publishMarmotKeyPackages() { - val manager = - marmotManager ?: run { - Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" } - return - } - if (!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 -> - 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 }}" - } - client.publish(event, relays) - } - } - } - - /** - * Generate and publish initial KeyPackage for this account. - */ - suspend fun publishMarmotKeyPackage() { - val manager = marmotManager ?: return - if (!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)}…" - } - cache.justConsumeMyOwnEvent(event) - 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. - */ - private suspend fun ensureMarmotKeyPackagePublished() { - val manager = marmotManager ?: return - if (!isWriteable()) return - try { - val hasBundle = manager.hasActiveKeyPackages() - Log.d("MarmotDbg") { - "ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${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 = marmotManager ?: return false - return manager.hasActiveKeyPackages() - } - - /** - * Create a new Marmot MLS group. - */ - suspend fun createMarmotGroup(nostrGroupId: HexKey) { - val manager = marmotManager ?: return - if (!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. - 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, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val metadata = manager.groupMetadata(nostrGroupId) - if (metadata != null && metadata.adminPubkeys.contains(signer.pubKey)) { - val remaining = metadata.adminPubkeys.filter { it != 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 != signer.pubKey } - if (heir != null) remaining.add(heir) - } - if (remaining.isNotEmpty()) { - val demoted = metadata.copy(adminPubkeys = remaining) - val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted) - 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 marmotGroupList.rooms) stops surfacing the group. - marmotGroupList.removeGroup(nostrGroupId) - 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 ${signer.pubKey.take(8)}…" } - marmotManager?.resetAllState() - for (groupId in marmotGroupList.allGroupIds()) { - 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, - ) { - Log.d("MarmotDbg") { - "removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " + - "groupRelays=${groupRelays.size}" - } - val manager = - marmotManager ?: run { - Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" } - return - } - if (!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 = 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 }}" - } - 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, - ) { - val manager = marmotManager ?: return - if (!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 = marmotGroupList.getOrCreateGroup(nostrGroupId) - manager.syncMetadataTo(nostrGroupId, chatroom) - 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, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val metadata = manager.groupMetadata(nostrGroupId) ?: return - if (metadata.adminPubkeys.contains(targetPubKey)) return - - val outboxRelayStrings = 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, - ) { - val manager = marmotManager ?: return - if (!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 = outboxRelays.flow.value.map { it.url } - val updated = - metadata - .copy(adminPubkeys = remaining) - .withMergedRelays(outboxRelayStrings) - updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) - } - suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { @@ -5988,14 +3386,6 @@ class Account( .mapNotNull { it.event } /** Publishes the given events to each of the given relays. No-op if either list is empty. */ - fun republishEventsTo( - events: List, - relays: Set, - ) { - if (relays.isEmpty() || events.isEmpty()) return - events.forEach { client.publish(it, relays) } - } - suspend fun requestToVanish( relays: List, reason: String, @@ -6104,7 +3494,7 @@ class Account( // restoreAll() above has already restored any previously // generated bundles. Only generate-and-publish if no active // bundle exists in memory after restore. - ensureMarmotKeyPackagePublished() + marmot.ensureMarmotKeyPackagePublished() // Sync MIP-01 metadata from restored groups to chatrooms and // re-hydrate decrypted messages from persistent storage. @@ -6168,10 +3558,10 @@ class Account( concordSessions.revision.sample(500).collect { refreshConcordChannelIndex() // A revision also bumps when a base-rotation rekey lands; adopt ours if present. - runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } // A rotation we were *excluded* from produces no rekey to drain, so it can only be // found by re-resolving the invite link we joined through. Rate-limited internally. - runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } + runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt new file mode 100644 index 0000000000..6fc115f11d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -0,0 +1,1058 @@ +/* + * 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.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.concurrent.ConcurrentHashMap + +/** Name of the default Concord community Admin role minted by "Make admin". */ +private const val CONCORD_ADMIN_ROLE = "Admin" + +/** + * How often a joined Concord community's stored invite link is re-resolved to check whether + * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is + * rare and silent, so this trades detection latency for not turning the revision tick into a + * relay-fetch loop. + */ +private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L + +/** + * Concord (encrypted communities) orchestration for an [Account]: join/create/ + * invite flows, channel messages/reactions/edits/typing, roles and moderation, + * community refound/recovery, metadata and channel management, and control-plane + * sync. Event building lives in the commons `ConcordActions`/`ConcordModeration` + * objects; this class wires them to the account's signer, session manager, + * channel list, and relay client. Rumor ingestion stays on [Account] + * (`consumeConcordRumorGated`), which is wired into `ConcordSessionManager`. + */ +class AccountConcordActions( + private val account: Account, +) { + /** + * Add a joined Concord community (secret-bearing entry) to the private kind-13302 + * list, and announce a self-signed Guestbook JOIN so this member is visible to + * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). + */ + suspend fun joinConcordCommunity( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ) { + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(entry)) + announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) + } + + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ + private suspend fun announceConcordGuestbookJoin( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey?, + inviteLabel: String?, + ) { + if (!account.isWriteable()) return + val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildGuestbookJoin(account.signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) + account.concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** + * Create a new Concord community: mint its genesis (metadata + #general), + * publish the owner-signed genesis wraps to [relays] (or our outbox), and add + * the secret-bearing entry to the kind-13302 joined list. Returns the new + * community id, or null if not writeable. + */ + suspend fun createConcordCommunity( + name: String, + description: String? = null, + relays: List = emptyList(), + icon: ImagePointer? = null, + ): String? { + if (!account.isWriteable()) return null + val relayUrls = + relays.ifEmpty { + account.outboxRelays.flow.value + .map { it.url } + } + val community = ConcordActions.createCommunity(account.signer, name, TimeUtils.now(), description, relayUrls, icon) + + val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + community.genesisWraps.forEach { account.client.publish(it, publishTo) } + + joinConcordCommunity( + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = relayUrls, + name = name, + addedAt = TimeUtils.now() * 1000, + ), + ) + return community.communityIdHex + } + + /** + * Mint a shareable invite link for a joined community and publish its + * kind-33301 public bundle to the community relays. Returns the `…/invite/…` + * URL, or null if the community isn't joined or isn't writeable. + */ + suspend fun mintConcordInvite( + communityId: String, + base: String = "https://amethyst.social", + ): String? { + if (!account.isWriteable()) return null + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return null + val invite = + ConcordActions.inviteFor( + communityIdHex = entry.id, + ownerPubKey = entry.owner, + ownerSaltHex = entry.ownerSalt, + communityRootHex = entry.root, + rootEpoch = entry.rootEpoch, + name = entry.name, + relays = entry.relays, + ) + val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) + + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) + return minted.url + } + + /** Drop a joined Concord community from the private kind-13302 list by its id. */ + suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) + + /** + * Redeem a Concord invite link (`…/invite/#`): parse it, fetch + * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it + * with the fragment token, and add the resulting secret-bearing entry to the + * kind-13302 joined list. + * + * Returns a [ConcordInviteResult] that separates the failure modes so the UI can + * both explain what went wrong and decide whether a retry could ever help — a + * bundle we can't open (e.g. minted by a newer client) must not strand the user + * on a spinner that retries forever. + * + * A bundle whose `expires_at` has passed is rejected with + * [ConcordInviteResult.Expired]. Expiry is resolved inside + * [ConcordActions.classifyInvite], so it is enforced on every redeem path rather + * than being a field nobody reads. + * + * **This must only ever be called from an explicit user action.** It contacts + * relay URLs carried in the link (chosen by whoever minted it) and publishes a + * Guestbook JOIN signed by this account, so calling it on deep-link arrival would + * leak the user's IP and enroll them without consent — see `ConcordInviteScreen`. + * + * If the resolved community is already in the joined list, this returns + * [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook + * JOIN, so reopening an old invite for a community you're already in simply takes + * you to it. + */ + suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { + if (!account.isWriteable()) return ConcordInviteResult.InvalidLink + val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink + + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return ConcordInviteResult.NotReachable + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + + // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a + // stale openable copy) so we honour revocation and can tell the user *why* a link won't open + // instead of stranding them on a spinner that retries a link we can never redeem. + val bundle = + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired + InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked + InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible + InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable + } + + // Already a member? Just take the user to the community. Re-following and re-announcing a + // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an + // old invite is reopened, so short-circuit to Joined — the screen forwards to the community + // either way ("take me there", not "join again"). + if (account.concordChannelList.liveCommunities.value + .any { it.id == bundle.communityId } + ) { + return ConcordInviteResult.Joined(bundle.communityId) + } + + val entry = + ConcordCommunityListEntry( + id = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + relays = bundle.relays, + name = bundle.name, + addedAt = TimeUtils.now() * 1000, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), + ) + joinConcordCommunity(entry) + return ConcordInviteResult.Joined(bundle.communityId) + } + + /** + * Post [text] to a Concord channel: derive the channel plane key, build an + * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), + * fold it locally for an instant echo, and publish it to the community's relays. + * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes + * straight to the community relay set. Returns false if not writeable or the + * community isn't currently joined/folded. + */ + suspend fun sendConcordChannelMessage( + communityId: String, + channelIdHex: String, + text: String, + replyTo: Note? = null, + replyMode: ReplyMode = ReplyMode.INLINE, + imetas: List = emptyList(), + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + + // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the + // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). + val emojiTags = + account.emoji + .findEmojiTags(text) + .map { it.toTagArray() } + .toTypedArray() + + val parent = replyTo?.event + val wrap = + when { + // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when + // the user attached media); an inline reply is a kind-9 message quoting the parent; a + // fresh post is a plain kind-9 message. + parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> + ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) + parent != null && replyMode == ReplyMode.MINICHAT -> + ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + parent != null -> + ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + else -> + ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) + } + trackConcordDelivery(entry, channelKey, wrap) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Send a channel message carrying encrypted image attachments ([imetas], built by the composer + * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are + * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no + * attachments this is just a plain [sendConcordChannelMessage]. + */ + suspend fun sendConcordChannelImageMessage( + communityId: String, + channelIdHex: String, + text: String, + imetas: List, + ): Boolean { + if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. + val emojiTags = + account.emoji + .findEmojiTags(text) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + trackConcordDelivery(entry, channelKey, wrap) + publishConcordWrap(entry, wrap) + return true + } + + /** + * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors + * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's + * channel/epoch, wraps it on the plane, and publishes it — so the reaction stays + * inside the encrypted channel (never a plaintext public kind-7 that would leak + * the message id). [note] must be a Concord channel message (carries a + * [ConcordChannel] gatherer). + */ + suspend fun reactToConcordMessage( + note: Note, + reaction: String, + ): Boolean { + if (!account.isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to + // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. + val emojiTags = + account.emoji + .findEmojiTags(reaction) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Edit my own Concord channel message [note] to [newText]. Mirrors + * [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the + * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays + * inside the encrypted channel (a public edit would e-tag the private rumor id onto + * public relays). The receiving side overlays the newest edit onto the target message; + * only the *original author's* edits are applied, so we gate to my own kind-9 messages. + * Returns false if [note] isn't an editable Concord message I authored. + */ + suspend fun editConcordChannelMessage( + note: Note, + newText: String, + ): Boolean { + if (!account.isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + // Edits only apply to plain kind-9 messages, and only the author may edit their own. + if (target !is ChatEvent || target.pubKey != account.signer.pubKey) return false + + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. + val emojiTags = + account.emoji + .findEmojiTags(newText) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at + * most every few seconds while composing. Not folded locally (we never show our own typing); + * ephemeral, so relays broadcast but never store it. + */ + suspend fun sendConcordTyping( + communityId: String, + channelIdHex: String, + ) { + if (!account.isWriteable()) return + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ + private fun publishConcordWrap( + entry: ConcordCommunityListEntry, + wrap: Event, + ) { + account.concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** + * Registers an own Concord channel message with the delivery tracker so its chat + * bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed + * shows the inner rumor, so we re-open the wrap (we just built it, so this always + * succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions + * and typing wraps skip this — they never become a feed row. + */ + private fun trackConcordDelivery( + entry: ConcordCommunityListEntry, + channelKey: GroupKey, + wrap: Event, + ) { + val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays) + } + + // ── Concord roles & moderation (CORD-04) ───────────────────────────────── + // Each publishes a Control Plane edition; authority is enforced at fold time by + // every client's AuthorityResolver, so a call by someone who doesn't outrank the + // target is simply dropped on fold. Owner-authored calls always take effect. + + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ + suspend fun grantConcordRole( + communityId: String, + member: HexKey, + roleIds: List, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val wrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** The default community Admin role: position 1, holding every management + moderation permission. */ + private fun concordAdminRole() = + RoleEntity( + name = CONCORD_ADMIN_ROLE, + position = 1, + permissions = + ConcordPermissions + .of( + ConcordPermissions.MANAGE_ROLES, + ConcordPermissions.MANAGE_CHANNELS, + ConcordPermissions.MANAGE_METADATA, + ConcordPermissions.KICK, + ConcordPermissions.BAN, + ConcordPermissions.MANAGE_MESSAGES, + ConcordPermissions.CREATE_INVITE, + ).toWire(), + ) + + /** + * If [note] is a Concord channel message whose author the OWNER may toggle + * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner + * qualifies — the Admin role sits at position 1 and the resolver requires the + * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for + * the owner's own note, the owner as target, or a non-owner actor. + */ + fun concordAdminTarget(note: Note): Triple? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == account.signer.pubKey) return null + val communityId = channel.channelId.communityId + val state = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value ?: return null + if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null + val adminRoleId = + state.roles.entries + .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + ?.key + val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) + return Triple(communityId, author, isAdmin) + } + + /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ + suspend fun makeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = session.controlPlaneKey() + + val existing = + session.state.value + ?.roles + ?.entries + ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + val roleIdHex = + existing?.key ?: run { + val roleId = RandomInstance.bytes(32) + val roleWrap = ConcordModeration.defineRole(account.signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, roleWrap) + roleId.toHexKey() + } + + val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** Revoke all roles from [member] (demote an admin back to a plain member). */ + suspend fun removeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val grantWrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** + * If [note] is a Concord channel message whose author this account is allowed to + * ban — the actor outranks the target and holds the BAN permission, and the target + * is neither the owner nor the actor — returns `(communityId, memberHex)`. Null + * otherwise, so the UI offers Ban only where we are willing to act. + * + * The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the + * BANLIST is a single whole-list entity, so neither this client's fold nor Armada's + * rank-checks the *contents* of a banlist edition — both gate only on the author's + * BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its + * role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin + * above them is therefore *accepted* by every client today. Since we cannot refuse + * such a ban without diverging from Armada, we at least refuse to author one — this + * restricts what we write, never what we accept, so it cannot split consensus. + * Enforcing it on the fold needs a spec change; see the QA plan's open findings. + */ + fun concordBanTarget(note: Note): Pair? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == account.signer.pubKey) return null + val communityId = channel.channelId.communityId + val authority = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority ?: return null + if (authority.isOwner(author)) return null + // The owner short-circuits rather than going through canActOn: canActOn starts at + // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put + // the owner on the banlist (see the KDoc) — routing the owner through it would let them be + // locked out of moderating their own community. + val canBan = authority.isOwner(account.signer.pubKey) || authority.canActOn(account.signer.pubKey, author, ConcordPermissions.BAN) + return if (canBan) communityId to author else null + } + + /** Add [member] to the community banlist. */ + suspend fun banConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val wrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Remove [member] from the community banlist. */ + suspend fun unbanConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val wrap = ConcordModeration.unban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── + // A ban is a soft removal — the banned member still holds the room key and can + // still decrypt traffic; every client just declines to *show* their posts. A + // Refounding is the hard removal: it rotates the community_root, so a removed + // member's key stops working for anything published afterwards. + + /** + * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, + * roll the `community_root`, re-key every retained member (Guestbook membership ∪ + * observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted + * Control Plane under the new root. A removed member keeps the prior root (so + * their history stays readable) but receives no blob, so they can never decrypt + * anything published after the rotation. + * + * Requires ownership or the BAN permission; returns false otherwise (or if the + * community isn't joined/writeable, or a target is the owner). + */ + suspend fun refoundConcordCommunity( + communityId: String, + removed: Set, + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val state = session.state.value ?: return false + val authority = state.authority + val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN) + if (!iCanBan) return false + val removedLower = removed.mapTo(HashSet()) { it.lowercase() } + if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + + // 1. Ban the removed members on the current Control Plane so the compacted snapshot — + // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally + // first, so each subsequent edition chains onto the updated banlist head. + for (target in removedLower) { + val banWrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, banWrap) + } + + // 2. Recipient set: everyone we're keeping, minus the removed and the already-banned. + // Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the + // Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other + // clients need not), so building the set without observed authors silently expelled every + // member who had only ever posted: they hold no role, receive no blob, and the Refounding + // strands them. That mainly hit cross-client communities, where Armada members are the + // bulk of the roster. + // + // Still a floor, not a census (see allMembers): a member who joined without a Guestbook + // motion, holds no role, and has never posted leaves no trace to find, so a Refounding + // cannot re-key them. Stranded recovery is what gets those members back. + val recipients = + (session.allMembers() + account.signer.pubKey) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removedLower) + removeAll(authority.bannedMembers()) + }.toList() + + // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. + val entry = session.entry + val newRoot = RandomInstance.bytes(32) + val build = + ConcordActions.buildRefounding( + rotatorSigner = account.signer, + communityId = communityId, + priorRoot = entry.root.hexToByteArray(), + newRoot = newRoot, + rootEpoch = entry.rootEpoch, + priorControlWraps = session.controlPlaneWraps(), + priorControlKey = session.controlPlaneKey(), + recipientsXOnly = recipients, + createdAt = TimeUtils.now(), + ) + + // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs + // (the key that unlocks it) to the community relays. + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isNotEmpty()) { + build.controlWraps.forEach { account.client.publish(it, publishTo) } + build.rekeyWraps.forEach { account.client.publish(it, publishTo) } + } + + // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and + // re-folds the compacted Control Plane (with the ban), dropping the removed members. + adoptConcordRoot(entry, newRoot, build.newEpoch) + return true + } + + // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered + // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not + // adopted — and re-published — twice on successive revision ticks. + private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) + + /** + * Persist a rotated access root/epoch for [entry], keeping the prior root as a + * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the + * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — + * without this, cascading removals would lose everyone but the roster). No-op if + * this exact rotation was already adopted. + */ + private suspend fun adoptConcordRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + ) { + if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + val next = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = held, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left + // out of would be unrecoverable. + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, + // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) + // must survive our rotation write, or we delete their data on every rekey. + residue = entry.residue, + ) + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) + announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) + } + + /** + * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for + * each joined community, look for our new root among the kind-3303 wraps seen at + * our next base-rekey address. If a role-authorized rotator (owner or a current, + * non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the + * session rebuilds at the new epoch and its next-rekey address moves on, so a stale + * wrap never re-triggers. Called on every Concord revision tick. + * + * Authority is the roster, never key possession: any non-banned BAN-holder may + * rotate, including for the owner. The owner deliberately does NOT refuse a root + * authored by someone else — refusing would strand the owner alone on the dead + * epoch whenever an admin legitimately rotates, and would diverge from Armada, + * which forks a community across clients. Self-escalation to BAN is prevented + * upstream by the role rank gate in AuthorityResolver. + * + * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, + * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the + * owner included) by omission — nothing on this receive path can prevent it. The + * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves + * the invite link the membership was joined through and merges forward. + */ + internal suspend fun drainConcordRekeys() { + if (!account.isWriteable()) return + for (session in account.concordSessions.sessions()) { + val wraps = session.pendingBaseRekeyWraps() + if (wraps.isEmpty()) continue + val entry = session.entry + val received = + ConcordActions.openBaseRekey( + wraps = wraps, + baseRekey = session.nextBaseRekeyKey(), + recipientSigner = account.signer, + priorRoot = entry.root.hexToByteArray(), + rootEpoch = entry.rootEpoch, + ) ?: continue + if (received.newEpoch <= entry.rootEpoch) continue + val authority = session.state.value?.authority ?: continue + + // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder + // who has themselves been banned could still rotate the whole community. + val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) + if (!authorized) continue + adoptConcordRoot(entry, received.newRoot, received.newEpoch) + } + } + + // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the + // Concord revision tick (which fires on every structural change) without turning it into a + // relay-fetch loop. + private val lastConcordRecoveryCheck = ConcurrentHashMap() + + /** + * Stranded recovery (CORD-05/06 receive path). A Refounding carries only + * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left + * out of the rekey recipient set receives nothing and sits on the dead epoch + * forever while everyone else moves on. This happens to any member, the owner + * included, and [drainConcordRekeys] cannot prevent it: there is no message to + * miss detecting. + * + * The way back is the invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and + * carried through every rotation by [adoptConcordRoot]). The community keeps + * re-minting its bundle at that same addressable coordinate, so a bundle there at + * a **strictly higher** epoch than ours proves we were left behind — and carries + * the new root. Same or lower epoch is a no-op. Memberships with no link (direct + * invites, legacy entries) are inert here; that is expected, not an error. + * + * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps + * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the + * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays + * derivable). We then re-announce the Guestbook at the new epoch, exactly as an + * ordinary rotation does, so the recovered member is visible to whoever refounds + * next instead of being silently dropped again. + * + * Called on the Concord revision tick, but rate-limited per community + * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. + */ + internal suspend fun recoverStrandedConcordCommunities() { + if (!account.isWriteable()) return + val now = TimeUtils.nowMillis() + for (entry in account.concordChannelList.liveCommunities.value) { + val inviteRef = entry.inviteRef ?: continue + val last = lastConcordRecoveryCheck[entry.id] + if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue + lastConcordRecoveryCheck[entry.id] = now + + val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue + val relays = + ( + parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ).toSet() + if (relays.isEmpty()) continue + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue + + val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue + if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue + Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged)) + announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) + } + } + + /** + * Replace the community metadata (name / icon / description / relays) with a new + * Control-Plane edition. Honored on fold only when this account holds + * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. + */ + suspend fun editConcordMetadata( + communityId: String, + name: String, + description: String?, + icon: ImagePointer?, + banner: ImagePointer?, + relays: List, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) + val wrap = ConcordModeration.editMetadata(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold + * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on + * the same predicate. The channel id is a fresh random 32-byte entity id. + */ + suspend fun createConcordChannel( + communityId: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val channelId = RandomInstance.bytes(32) + val channel = ChannelEntity(name = name.trim()) + val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ + suspend fun renameConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + // Carry the standing definition forward and change only the name. A ChannelEntity built from + // scratch defaults `private` and `voice` to false, so renaming a private channel used to + // publish an edition declaring it PUBLIC — and a voice channel became a text channel. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) + val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ + suspend fun deleteConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + // Same as rename: preserve the standing flags so a tombstone does not also silently + // reclassify the channel it retires. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) + val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from + * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT + * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a + * card can show what the link opens, or null if the link is invalid/unreadable. + */ + suspend fun peekConcordInvite(url: String): CommunityInvite? { + val parsed = ConcordActions.parseInviteLink(url) ?: return null + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return null + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + } + + /** + * Bootstrap the Concord hub from the network: fetch this account's kind-13302 + * joined-communities list and fold the newest into [LocalCache], so communities + * we joined on another Concord client with this key surface here. + * + * We query a wide relay set because different Concord clients publish this + * private list to different places: the reference clients (Armada/Vector) push + * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may + * also have copied it onto their **own** outbox/read relays. Our normal account + * subscription never asks for kind 13302, so without this explicit fetch a + * community joined on Armada would never appear — even if the list sits on the + * user's own outbox. + * + * Read-only import: kind 13302 is replaceable, so folding an older copy is a + * no-op and this is safe to call on every hub open. Merging our own edits with + * a foreign writer's is a separate concern (newest-wins replaceable). + * + * [extraRelays] are additional relays to query — the bootstrap relays saved on the + * bottom-bar tabs of pinned communities. A community's private list frequently lives + * only on the community's own relays (never the user's outbox), so a community pinned + * to the bottom bar would otherwise never surface when opened cold. + */ + suspend fun importConcordCommunities(extraRelays: Set = emptySet()) { + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + val relays = (stock + account.mineRelays.flow.value + account.outboxRelays.flow.value + extraRelays).toSet() + if (relays.isEmpty()) return + val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey)) + // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give + // the fetch a generous window to drain every relay before we pick the newest copy. + val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) + val newest = events.filterIsInstance().maxByOrNull { it.createdAt } + val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0 + Log.d( + "Concord", + "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + + "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", + ) + newest?.let { account.cache.justConsumeMyOwnEvent(it) } + } + + /** + * One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox + * fill in without the user opening each channel one by one. Per channel, a channel read before is + * caught up from its last-read time (accurate unread badge + the missed messages ready when it + * opens) while a channel never read pulls only its single newest wrap for a preview — see + * [ConcordSubscriptionPlanner.channelPreviewFilters]. + * + * This is deliberately **not** a live subscription: every wrap the drain pulls flows through the + * global cache connector (`CacheClientConnector` → `LocalCache.justConsume` → `concordSessions.ingest`), + * so it lands in the channel's message store the previews/unread counts read — and the always-on + * plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward. + * So this only needs to run when a community's channels first fold (the account preload) or its + * screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay. + */ + suspend fun warmConcordChannelPreviews(entries: List) { + val filters = + entries.flatMap { entry -> + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value ?: return@flatMap emptyList() + ConcordSubscriptionPlanner.channelPreviewFilters( + entry, + state, + lastReadFor = { channelIdHex -> + account.loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) + }, + accountPubKey = account.userProfile().pubkeyHex, + ) + } + if (filters.isEmpty()) return + val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } + account.client.fetchAll(filters = byRelay, timeoutMs = 20_000L) + } + + /** + * COMPLETE-mode Control-Plane sync — Armada's plane-sweep discipline for the one plane that must + * never fold on a truncated edition set. + * + * The Control Plane defines the channel list, the roster and the banlist, so a *partial* fold + * silently drops channels or mis-renders membership. Two ways that happens, both closed here: + * - **Forward-cursor gap:** the live plane subscription advances a `since` cursor, so an edition + * with a `created_at` below the high-water mark that we never actually ingested — an unban + * published while we were offline, a CORD-06 compaction re-wrap under a newly-held epoch — is + * never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the + * whole plane every run. + * - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can + * crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until` + * cursors until a plane is drained), so the fold sees every edition regardless of the cap. + * + * Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the + * priors). Wraps ingest through the global cache connector → [concordSessions] like every other + * Concord drain; AUTH is the shared stream-key handler. Merging communities that share a relay into + * one filter is safe here precisely because we page — the cap no longer truncates. The live control + * subscription still carries brand-new editions in real time; this is the periodic completeness pass. + */ + suspend fun syncConcordControlPlanes(entries: List) { + if (entries.isEmpty()) return + val authorsByRelay = HashMap>() + for (entry in entries) { + for (sub in ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + } + } + if (authorsByRelay.isEmpty()) return + // No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a + // plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap. + val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) } + var drained = 0 + account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } + Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt new file mode 100644 index 0000000000..e45fd03764 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -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 { + 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, + ) { + 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, + ) { + 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 = + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt new file mode 100644 index 0000000000..d8be5a68c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -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, + ): 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): 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.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 = emptyList(), + geohashes: List = 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 = + 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, + ) { + 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, + ) { + // 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 = emptyList(), + geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = 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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt new file mode 100644 index 0000000000..1c9f6ae8bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -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? = 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? = 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, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = 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) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt new file mode 100644 index 0000000000..c906079d84 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt @@ -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() + + 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() + + 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() + + // 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() + val accountNip04Pruned = HashMap() + val roomNip04Pruned = HashMap() + // 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 { + val host = note.rumorHost ?: return emptyList() + + val children = mutableListOf() + 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() + + 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) { + 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() + + 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) { + 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() + + 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() + + 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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt new file mode 100644 index 0000000000..3af6813384 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt @@ -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 { + 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 { + 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 { + 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 { + if (text.isBlank()) return emptyList() + + return cache.ephemeralChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findLiveActivityChannelsStartingWith(text: String): List { + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt new file mode 100644 index 0000000000..c1e3443d94 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt @@ -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? +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt new file mode 100644 index 0000000000..33ed7a5369 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -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 = + 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 = + 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 = 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 = computeRelayListToBroadcast(event, mutableSetOf()) + + private fun computeRelayListToBroadcast( + event: Event, + visited: MutableSet, + ): Set { + // 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() + 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 { + 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) = 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) { + 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 signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) { + 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 signWithAndSendPrivately( + template: EventTemplate, + signer: NostrSigner, + relayList: Set, + ): T { + val event = signer.sign(template) + account.cache.justConsumeMyOwnEvent(event) + if (relayList.isNotEmpty()) account.client.publish(event, relayList) + return event + } + + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): 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 signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = 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 signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = 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, + relays: Set, + ) { + if (relays.isEmpty() || events.isEmpty()) return + events.forEach { account.client.publish(it, relays) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index bdad25dece..7ffa080688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -52,11 +52,9 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.LocalCache.observeEvents -import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent @@ -184,7 +182,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.core.isRegular import com.vitorpamplona.quartz.nip01Core.core.isReplaceable -import com.vitorpamplona.quartz.nip01Core.core.tagValueContains import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider @@ -202,8 +199,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -211,7 +206,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -219,9 +213,6 @@ import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed @@ -261,7 +252,6 @@ import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -278,7 +268,6 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent -import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent @@ -374,7 +363,6 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @@ -410,7 +398,6 @@ import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEven import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent -import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -545,6 +532,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { */ val observables = FilterIndex() + /** + * Memory-reclaim policy (soft-cache trims + hidden/old/expired/superseded event + * pruning) and the shared [CachePruner.unlinkAndRemove] removal primitive. + */ + val pruner = CachePruner(this) + + /** Prefix/content search over users, notes, and channels. */ + val search = CacheSearch(this) + fun Filter.match(note: Note): Boolean { val event = note.event return if (event != null) { @@ -1031,7 +1027,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // quoted note. Count it as a boost so it shows in the quoted note's repost // counter alongside kind:6/kind:16 reposts. The quoted note is deliberately // kept out of `replyTo` so the quote still renders as a root post in the home - // feed (see Note.isNewThread); deletion cleanup lives in unlinkAndRemove. + // feed (see Note.isNewThread); deletion cleanup lives in CachePruner.unlinkAndRemove. addQuoteBoosts(event, note, replyTo) refreshNewNoteObservers(note) @@ -1635,14 +1631,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { * * Removal has two halves: unlinking the note from everything that points AT it * (its parents, channels, and the per-user report/card/status/poll indexes — - * all handled by [unlinkAndRemove]); and dealing with the note's OWN children + * all handled by [CachePruner.unlinkAndRemove]); and dealing with the note's OWN children * (the notes that point at IT). The delete path and the prune path share the * first half and differ only on the second: * - delete (here): the children are independent events and stay in the cache; * [Note.detachFromChildren] only severs their back-reference so the removed * shell can neither leak (held alive by a child's `replyTo`) nor be later * resurrected by `computeReplyTo` as a second Note for the same id. - * - prune (see [unlinkAndRemove] callers): the whole child subtree is removed. + * - prune (see [CachePruner.unlinkAndRemove] callers): the whole child subtree is removed. * * Rumors additionally drop the envelope notes that delivered them. */ @@ -1651,7 +1647,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { deleteNote.detachFromChildren() - unlinkAndRemove(deleteNote) + pruner.unlinkAndRemove(deleteNote) } /** @@ -3116,637 +3112,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { return false } - fun findUsersStartingWith( - username: String, - forAccount: Account?, - ): List { - if (username.isBlank()) return emptyList() - - checkNotInMainThread() - - val key = decodePublicKeyAsHexOrNull(username) - - if (key != null) { - val user = getUserIfExists(key) - if (user != null) { - return listOfNotNull(user) - } - } - - val dualCase = - listOf( - DualCase(username.lowercase(), username.uppercase()), - ) - - val finds = - 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 { - checkNotInMainThread() - - if (text.isBlank()) return emptyList() - - val key = decodeEventIdAsHexOrNull(text) - - if (key != null) { - val note = getNoteIfExists(key) - val noteEvent = note?.event - val newNote = - if (noteEvent is AddressableEvent) { - val addressableNote = getAddressableNoteIfExists(noteEvent.address()) - if (addressableNote?.event?.id == note.idHex) { - addressableNote - } else { - note - } - } else { - note - } - - if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) { - return listOfNotNull(newNote) - } - } - - return 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 - } + - 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 { - if (text.isBlank()) return emptyList() - - val key = decodeEventIdAsHexOrNull(text) - if (key != null) { - getPublicChatChannelIfExists(key)?.let { - return listOf(it) - } - } - - return publicChatChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - - fun findEphemeralChatChannelsStartingWith(text: String): List { - if (text.isBlank()) return emptyList() - - return ephemeralChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - - fun findLiveActivityChannelsStartingWith(text: String): List { - if (text.isBlank()) return emptyList() - - try { - val parsed = Nip19Parser.uriToRoute(text)?.entity - if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) { - return listOf(getOrCreateLiveChannel(parsed.address())) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - } - - return liveChatChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - fun getPeopleListNotesFor(user: User): List = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) - fun cleanMemory() { - Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } - notes.cleanUp() - Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${notes.size()}" } - - Log.d("LargeCache") { "Addressables cleanup started. Current size: ${addressables.size()}" } - addressables.cleanUp() - Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${addressables.size()}" } - - Log.d("LargeCache") { "Users cleanup started. Current size: ${users.size()}" } - users.cleanUp() - Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${users.size()}" } - } - - fun cleanObservers() { - notes.forEach { _, it -> it.clearFlow() } - addressables.forEach { _, it -> it.clearFlow() } - } - - fun pruneHiddenMessagesChannel( - channel: Channel, - account: Account, - ) { - val toBeRemoved = channel.pruneHiddenMessages(account) - - val childrenToBeRemoved = mutableListOf() - - 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) { - ephemeralChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - geohashChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - liveChatChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - publicChatChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - 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 PRESENCE_PRUNE_AGE_SECONDS = 20L * 60L - - fun pruneOldMessagesChannel(channel: Channel) { - val toBeRemoved = channel.pruneOldMessages() - - val childrenToBeRemoved = mutableListOf() - - 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() - PRESENCE_PRUNE_AGE_SECONDS) - } - - 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() - - ephemeralChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - geohashChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - liveChatChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - publicChatChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - relayGroupChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - 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() - - // 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() - val accountNip04Pruned = HashMap() - val roomNip04Pruned = HashMap() - // 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", - ) - } - } - } - } - - fun removeIfWrap(note: Note): List { - val host = note.rumorHost ?: return emptyList() - - val children = mutableListOf() - getNoteIfExists(host.id)?.let { hostNote -> - (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> - 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 = - notes.filter { _, note -> - val noteEvent = note.event - if (noteEvent is AddressableEvent) { - noteEvent.createdAt < - (addressables.get(noteEvent.address())?.event?.createdAt ?: 0) - } else { - false - } - } - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> 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) { - checkNotInMainThread() - - val toBeRemoved = - 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() - - 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 [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 [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]. - */ - private fun unlinkAndRemove(note: Note) { - note.replyTo?.forEach { masterNote -> - masterNote.removeNote(note) - } - - note.inGatherers?.forEach { it.removeNote(note) } - - 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 -> - 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 { 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 { getNoteIfExists(it)?.removeTimestamp(note) } - } - - if (noteEvent is ReportEvent) { - noteEvent.reportedAuthor().forEach { - getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> - reports.removeReport(note) - reports.removeReportNamingUser(note) - } - } - - noteEvent.reportedPost().forEach { - getNoteIfExists(it.eventId)?.removeReport(note) - } - - noteEvent.reportedAddresses().forEach { - getAddressableNoteIfExists(it.address)?.removeReport(note) - } - } - - if (note is AddressableNote && noteEvent is ContactCardEvent) { - 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 { - getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note) - } - } - - note.clearFlow() - - notes.remove(note.idHex) - - 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) { - nextToBeRemoved.forEach { note -> unlinkAndRemove(note) } - } - - fun pruneExpiredEvents() { - checkNotInMainThread() - - val now = TimeUtils.now() - val versionsToBeRemoved = notes.filter { _, it -> it.event?.isExpirationBefore(now) == true } - val addressesToBeRemoved = addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true } - - val childrenToBeRemoved = mutableListOf() - - 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() - - val toBeRemoved = - account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex -> - (notes.filter { _, it -> it.event?.pubKey == userHex } + 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") - } - override fun markAsSeen( eventId: String, relay: NormalizedRelayUrl, @@ -3799,7 +3166,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { live.newNote(newNote) } - private fun refreshDeletedNoteObservers(newNote: Note) { + internal fun refreshDeletedNoteObservers(newNote: Note) { // Deletes don't have a filterable shape — every observer // might hold this note in its result set, so iterate them // all. The index doesn't help here. @@ -4072,413 +3439,35 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { ): Boolean = try { when (event) { - is AcceptedBadgeSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AdvertisedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppRecommendationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppSpecificDataEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestationRequestEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestorRecommendationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestorProficiencyEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AudioHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is AudioTrackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BadgeAwardEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is BadgeDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BlockedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BlossomServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NestsServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BroadcastRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is OldBookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarDateSlotEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarTimeSlotEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarRSVPEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CallAnswerEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallHangupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallIceCandidateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallOfferEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallRejectEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallRenegotiateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - // ============================================================ - // NIP-60 Cashu wallet + NIP-61 nutzaps + // Kinds with dedicated consume() logic: typed overloads that + // update channels, zaps, drafts, the deletion index, etc. + // Everything without per-kind logic falls through to the + // generic replaceable / regular groups at the bottom. // ============================================================ - is CashuWalletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - is CashuTokenEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CashuSpendingHistoryEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CashuMintQuoteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NutzapInfoEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NutzapEvent -> { - consume(event, relay, wasVerified) - } - - // ============================================================ - // NIP-87 Cashu mint discovery + recommendations - // ============================================================ - // All three are kind 3xxxx (parameterized-replaceable per the - // spec) but neither CashuMintEvent / FedimintEvent / - // MintRecommendationEvent extends AddressableEvent in Quartz - // today, so consumeBaseReplaceable's `check(event is - // AddressableEvent)` would crash. Route through - // consumeRegularEvent — downstream consumers - // (CashuMintDirectoryState, CashuWalletState) already dedupe - // by (pubKey, dTag) and keep the newest. Without these - // entries the dispatch falls into the "Event Not Supported" - // else branch and silently drops the event, so our own - // kind:38000 thumbs-up never lands in the cache. - is CashuMintEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FedimintEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is MintRecommendationEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChannelCreateEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ChannelHideMessageEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMessageEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMetadataEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMuteUserEvent -> { - consume(event, relay, wasVerified) - } - - is ChatMessageEncryptedFileHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChatMessageEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChatMessageRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Bolt12OfferListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ClassifiedsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FundraiserEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BirdexEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BirdDetectionEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is Ps1SaveEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CommunityDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommunityListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommunityPostApprovalEvent -> { - consume(event, relay, wasVerified) - } - - is ContactListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is DeletionEvent -> { - consume(event, relay, wasVerified) - } - - is DraftWrapEvent -> { - consume(event, relay, wasVerified) - } - - is EmojiPackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is EmojiPackSelectionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is EphemeralChatEvent -> { - consume(event, relay, wasVerified) - } - - is GeohashChatEvent -> { - consume(event, relay, wasVerified) - } - - is EphemeralChatListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + - // servers. Replaceable like its sibling lists; RelayGroupListState reads it from the - // addressable cache, so it must be stored (it was silently dropped before). - is SimpleGroupListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; - // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), - // so — exactly like the 10009 list above — it must be stored replaceably or the Concord - // hub stays empty even after the event arrives. - is ConcordCommunityListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GroupMetadataEvent -> { - consume(event, relay, wasVerified) - } - - is GroupMembersEvent -> { - consume(event, relay, wasVerified) - } - - is GroupAdminsEvent -> { - consume(event, relay, wasVerified) - } - - is GroupPinnedEvent -> { - consume(event, relay, wasVerified) - } + is NutzapEvent -> consume(event, relay, wasVerified) + is ChannelCreateEvent -> consume(event, relay, wasVerified) + is ChannelHideMessageEvent -> consume(event, relay, wasVerified) + is ChannelMessageEvent -> consume(event, relay, wasVerified) + is ChannelMetadataEvent -> consume(event, relay, wasVerified) + is ChannelMuteUserEvent -> consume(event, relay, wasVerified) + is CommunityPostApprovalEvent -> consume(event, relay, wasVerified) + is DeletionEvent -> consume(event, relay, wasVerified) + is DraftWrapEvent -> consume(event, relay, wasVerified) + is EphemeralChatEvent -> consume(event, relay, wasVerified) + is GeohashChatEvent -> consume(event, relay, wasVerified) + is GroupMetadataEvent -> consume(event, relay, wasVerified) + is GroupMembersEvent -> consume(event, relay, wasVerified) + is GroupAdminsEvent -> consume(event, relay, wasVerified) + is GroupPinnedEvent -> consume(event, relay, wasVerified) // 39003 (relay-declared roles) is durable group state like 39000/39001/39002: // route it onto the channel so a moderation UI can offer the relay's role set. - is SupportedRolesEvent -> { - consume(event, relay, wasVerified) - } + is SupportedRolesEvent -> consume(event, relay, wasVerified) - // Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants - // addressable is durable group state, so it's stored replaceably. The 9xxx - // moderation actions and join/leave requests are regular one-shot events the - // relay is authoritative for (it applies them and republishes the - // 39000/39001/39002); we store them so they're queryable and don't fall through - // to the "Not Supported" warning, but we don't act on them client-side. - is GroupParticipantsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PutUserEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RemoveUserEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is EditMetadataEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is DeleteEventEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is UpdatePinListEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is DeleteGroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CreateGroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CreateInviteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is JoinRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is LeaveRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ExternalIdentitiesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GenericRepostEvent -> { - consume(event, relay, wasVerified) - } - - is FhirResourceEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FileHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ProfileGalleryEntryEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FileServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FileStorageEvent -> { - consume(event, relay, wasVerified) - } - - is FileStorageHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FollowListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GeohashListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GoalEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is GenericRepostEvent -> consume(event, relay, wasVerified) + is FileStorageEvent -> consume(event, relay, wasVerified) is GiftWrapEvent -> { // A wrap with an empty content carries no NIP-44 ciphertext and can @@ -4492,177 +3481,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - is GroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitIssueEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitReplyEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPatchEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPullRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPullRequestUpdateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitStatusEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitRepositoryEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GitRepositoryStateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is UserGraspListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RootSiteEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NamedSiteEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RootNappletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NamedNappletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ChessGameEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RelayFeedsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is JesterEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is KeyPackageEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is KeyPackageRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameChallengeEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameAcceptEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessMoveEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameEndEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessDrawOfferEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is HashtagListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FavoriteAlgoFeedsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is HighlightEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is IndexerRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStoryPrologueEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStorySceneEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStoryReadingStateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InterestSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LabeledBookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveActivitiesEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesChatMessageEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesRaidEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesClipEvent -> { - consume(event, relay, wasVerified) - } - - is MeetingSpaceEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MeetingRoomEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MeetingRoomPresenceEvent -> { - consume(event, relay, wasVerified) - } - - is MusicTrackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MusicPlaylistEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PodcastEpisodeEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is LiveActivitiesEvent -> consume(event, relay, wasVerified) + is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) + is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) + is LiveActivitiesClipEvent -> consume(event, relay, wasVerified) + is MeetingRoomPresenceEvent -> consume(event, relay, wasVerified) is PodcastMetadataEvent -> { // Drop the known "Mock Podcast" spam flood instead of caching thousands of them. @@ -4673,133 +3496,27 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - is AuthoredPodcastsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FavoritePodcastsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Podcasting20EpisodeEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Podcasting20TrailerEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LnZapEvent -> { - consume(event, relay, wasVerified) - } - - is LnZapRequestEvent -> { - consume(event, relay, wasVerified) - } - - is OnchainZapEvent -> { - consume(event, relay, wasVerified) - } - - is Bolt12ZapEvent -> { - consume(event, relay, wasVerified) - } - - is NIP90StatusEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90ContentDiscoveryResponseEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90ContentDiscoveryRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90UserDiscoveryResponseEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90UserDiscoveryRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is LnZapPaymentRequestEvent -> { - consume(event, relay, wasVerified) - } - - is LnZapPaymentResponseEvent -> { - consume(event, relay, wasVerified) - } - - is LongTextNoteEvent -> { - consume(event, relay, wasVerified) - } - - is MetadataEvent -> { - consume(event, relay, wasVerified) - } - - is MuteListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NNSEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NipTextEvent -> { - consume(event, relay, wasVerified) - } - - is OtsEvent -> { - consume(event, relay, wasVerified) - } - - is PictureEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PrivateDmEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PrivateOutboxRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ProfileBadgesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ProxyRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PinListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PublicMessageEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PeopleListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RequestToVanishEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CodeSnippetEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ZapPollEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is LnZapEvent -> consume(event, relay, wasVerified) + is LnZapRequestEvent -> consume(event, relay, wasVerified) + is OnchainZapEvent -> consume(event, relay, wasVerified) + is Bolt12ZapEvent -> consume(event, relay, wasVerified) + is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified) + is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified) + is LongTextNoteEvent -> consume(event, relay, wasVerified) + is MetadataEvent -> consume(event, relay, wasVerified) + is NipTextEvent -> consume(event, relay, wasVerified) + is OtsEvent -> consume(event, relay, wasVerified) + is PollResponseEvent -> consume(event, relay, wasVerified) + is ReactionEvent -> consume(event, relay, wasVerified) + is LabelEvent -> consume(event, relay, wasVerified) + is ContactCardEvent -> consume(event, relay, wasVerified) + is ReportEvent -> consume(event, relay, wasVerified) + is RepostEvent -> consume(event, relay, wasVerified) + is StatusEvent -> consume(event, relay, wasVerified) + is TextNoteModificationEvent -> consume(event, relay, wasVerified) + is ConcordChatEditEvent -> consume(event, relay, wasVerified) + is WikiNoteEvent -> consume(event, relay, wasVerified) + is PaymentTargetsEvent -> consume(event, relay, wasVerified) is ChatEvent -> { consumeRegularEvent(event, relay, wasVerified).also { @@ -4811,6 +3528,18 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } + is PollEvent -> { + consumeRegularEvent(event, relay, wasVerified).also { + attachToRelayGroupIfScoped(event, relay) + } + } + + is ThreadEvent -> { + consumeRegularEvent(event, relay, wasVerified).also { + attachThreadToRelayGroupIfScoped(event, relay) + } + } + // ------------------------------------------------------------------ // Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29). // Timeline kinds attach into the group's BuzzWorkspaceChannel; the @@ -4821,87 +3550,78 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // BitChat `g` tag is absent, and it is handled below with the ephemerals. // ------------------------------------------------------------------ - is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) - is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) - // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) - // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. - is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified) - is ForumCommentEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ForumVoteEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - - // Buzz addressable/replaceable state. - is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is DmVisibilityEvent -> consume(event, relay, wasVerified) - is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified) - - // Buzz store-only regular kinds (queryable state; no timeline row yet). - is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) is MemberRemovedNotificationEvent -> consume(event, relay, wasVerified) is RelayMembershipListEvent -> consume(event, relay, wasVerified) is RelayAddMemberEvent -> consume(event, relay, wasVerified) is RelayRemoveMemberEvent -> consume(event, relay, wasVerified) - is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + is DmVisibilityEvent -> consume(event, relay, wasVerified) - // Buzz relay-signed sidecars and audit projections: store-only, queryable. - is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) + // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. + is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified) + + is StreamMessageV2Event, + is StreamMessageDiffEvent, + is JobRequestEvent, + is JobAcceptedEvent, + is JobProgressEvent, + is JobResultEvent, + is JobCancelEvent, + is JobErrorEvent, + is HuddleStartedEvent, + is HuddleParticipantJoinedEvent, + is HuddleParticipantLeftEvent, + is HuddleEndedEvent, + -> consumeBuzzTimelineEvent(event, relay, wasVerified) + + // Buzz store-only regular kinds (queryable state; no timeline row yet), + // plus relay-signed sidecars and audit projections. + is ForumCommentEvent, + is ForumVoteEvent, + is StreamMessagePinnedEvent, + is StreamMessageBookmarkedEvent, + is StreamMessageScheduledEvent, + is StreamReminderEvent, + is DmCreatedEvent, + is DmOpenEvent, + is DmAddMemberEvent, + is DmHideEvent, + is MemberAddedNotificationEvent, + is AgentTurnMetricEvent, + is ModerationBanEvent, + is ModerationTimeoutEvent, + is ModerationUntimeoutEvent, + is ModerationResolveReportEvent, + is ProductFeedbackEvent, + is RelayAdminAddMemberEvent, + is RelayAdminRemoveMemberEvent, + is RelayAdminChangeRoleEvent, + is SetWorkspaceProfileEvent, + is ArchiveRequestEvent, + is UnarchiveRequestEvent, + is ArchivedIdentityEvent, + is UnarchivedIdentityEvent, + is HuddleGuidelinesEvent, + is WorkflowTriggeredEvent, + is WorkflowStepStartedEvent, + is WorkflowStepCompletedEvent, + is WorkflowStepFailedEvent, + is WorkflowCompletedEvent, + is WorkflowFailedEvent, + is WorkflowCancelledEvent, + is WorkflowApprovalRequestedEvent, + is WorkflowApprovalGrantedEvent, + is WorkflowApprovalDeniedEvent, + is WorkflowTriggerEvent, + is ApprovalGrantEvent, + is ApprovalDenyEvent, + is AuditEntryEvent, + is ChannelSummaryEvent, + is PresenceSnapshotEvent, + -> consumeBuzzRegularEvent(event, relay, wasVerified) // Buzz ephemeral signals: transient by definition (20000-29999) — do not // pollute the note store, and do NOT mark the dialect from them (they are @@ -4926,165 +3646,211 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // pairing before any workspace relationship is established. is PairingEvent -> false - is PollEvent -> { - consumeRegularEvent(event, relay, wasVerified).also { - attachToRelayGroupIfScoped(event, relay) - } - } + // ============================================================ + // Replaceable / addressable kinds with no per-kind logic: + // the newest version per address is kept, older ones dropped. + // New kinds without custom consume logic go in one of the two + // groups below — an unlisted kind falls into the else branch + // and is rejected as unsupported. + // ============================================================ + is AcceptedBadgeSetEvent, + is AdvertisedRelayListEvent, + is AppDefinitionEvent, + is AppRecommendationEvent, + is AppSpecificDataEvent, + is AttestationEvent, + is AttestationRequestEvent, + is AttestorRecommendationEvent, + is AttestorProficiencyEvent, + is AudioTrackEvent, + is BadgeDefinitionEvent, + is BlockedRelayListEvent, + is BlossomServersEvent, + is NestsServersEvent, + is BroadcastRelayListEvent, + is BookmarkListEvent, + is OldBookmarkListEvent, + is CalendarEvent, + is CalendarDateSlotEvent, + is CalendarTimeSlotEvent, + is CalendarRSVPEvent, + is CashuWalletEvent, + is NutzapInfoEvent, + is ChannelListEvent, + is ChatMessageRelayListEvent, + is Bolt12OfferListEvent, + is ClassifiedsEvent, + is FundraiserEvent, + is BirdexEvent, + is Ps1SaveEvent, + is CommunityDefinitionEvent, + is CommunityListEvent, + is ContactListEvent, + is EmojiPackEvent, + is EmojiPackSelectionEvent, + is EphemeralChatListEvent, + // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + + // servers. Replaceable like its sibling lists; RelayGroupListState reads it from the + // addressable cache, so it must be stored (it was silently dropped before). + is SimpleGroupListEvent, + // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; + // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), + // so — exactly like the 10009 list above — it must be stored replaceably or the Concord + // hub stays empty even after the event arrives. + is ConcordCommunityListEvent, + // The relay-signed NIP-29 39004 AV-participants addressable is durable group state. + is GroupParticipantsEvent, + is ExternalIdentitiesEvent, + is FileServersEvent, + is FollowListEvent, + is GeohashListEvent, + is GitRepositoryEvent, + is GitRepositoryStateEvent, + is UserGraspListEvent, + is RootSiteEvent, + is NamedSiteEvent, + is RootNappletEvent, + is NamedNappletEvent, + is RelayFeedsListEvent, + is KeyPackageEvent, + is KeyPackageRelayListEvent, + is LiveChessGameChallengeEvent, + is LiveChessGameAcceptEvent, + is LiveChessMoveEvent, + is LiveChessGameEndEvent, + is LiveChessDrawOfferEvent, + is HashtagListEvent, + is FavoriteAlgoFeedsListEvent, + is IndexerRelayListEvent, + is InteractiveStoryPrologueEvent, + is InteractiveStorySceneEvent, + is InteractiveStoryReadingStateEvent, + is InterestSetEvent, + is LabeledBookmarkListEvent, + is MeetingSpaceEvent, + is MeetingRoomEvent, + is MusicTrackEvent, + is MusicPlaylistEvent, + is AuthoredPodcastsEvent, + is FavoritePodcastsListEvent, + is Podcasting20EpisodeEvent, + is Podcasting20TrailerEvent, + is MuteListEvent, + is NNSEvent, + is PrivateOutboxRelayListEvent, + is ProfileBadgesEvent, + is ProxyRelayListEvent, + is PinListEvent, + is PeopleListEvent, + // Buzz addressable/replaceable state. + is PersonaEvent, + is TeamEvent, + is ManagedAgentEvent, + is AgentProfileEvent, + is EngramEvent, + is WorkflowDefEvent, + is EventReminderEvent, + is PushLeaseEvent, + is WindowBoundsEvent, + is ArchivedIdentitiesListEvent, + is RelayDiscoveryEvent, + is RelayMonitorEvent, + is RelaySetEvent, + is ReleaseArtifactSetEvent, + is SearchRelayListEvent, + is SoftwareApplicationEvent, + is TrustedRelayListEvent, + is TrustProviderListEvent, + is VideoHorizontalEvent, + is VideoVerticalEvent, + is WebBookmarkEvent, + is ExerciseTemplateEvent, + -> consumeBaseReplaceable(event, relay, wasVerified) - is ThreadEvent -> { - consumeRegularEvent(event, relay, wasVerified).also { - attachThreadToRelayGroupIfScoped(event, relay) - } - } - - is PollResponseEvent -> { - consume(event, relay, wasVerified) - } - - is RoadEventReportEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RoadEventConfirmationEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RelayDiscoveryEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RelayMonitorEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ReactionEvent -> { - consume(event, relay, wasVerified) - } - - is LabelEvent -> { - consume(event, relay, wasVerified) - } - - is ContactCardEvent -> { - consume(event, relay, wasVerified) - } - - is RelaySetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ReportEvent -> { - consume(event, relay, wasVerified) - } - - is RepostEvent -> { - consume(event, relay, wasVerified) - } - - is ReleaseArtifactSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SealedRumorEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is SearchRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SoftwareApplicationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SoftwareAssetEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is StatusEvent -> { - consume(event, relay, wasVerified) - } - - is TextNoteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TextNoteModificationEvent -> { - consume(event, relay, wasVerified) - } - - is ConcordChatEditEvent -> { - consume(event, relay, wasVerified) - } - - is TorrentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TorrentCommentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TrustedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is TrustProviderListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoHorizontalEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoNormalEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VideoVerticalEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoShortEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VoiceEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VoiceReplyEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WakeUpEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WebBookmarkEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is WelcomeEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WikiNoteEvent -> { - consume(event, relay, wasVerified) - } - - is WorkoutRecordEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ExerciseTemplateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PaymentTargetsEvent -> { - consume(event, relay, wasVerified) - } + // ============================================================ + // Regular kinds with no per-kind logic: stored as plain notes. + // ============================================================ + is AudioHeaderEvent, + is BadgeAwardEvent, + is CallAnswerEvent, + is CallHangupEvent, + is CallIceCandidateEvent, + is CallOfferEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + is CashuTokenEvent, + is CashuSpendingHistoryEvent, + is CashuMintQuoteEvent, + // NIP-87 Cashu mint discovery + recommendations: all three are kind 3xxxx + // (parameterized-replaceable per the spec) but neither CashuMintEvent / + // FedimintEvent / MintRecommendationEvent extends AddressableEvent in Quartz + // today, so consumeBaseReplaceable's `check(event is AddressableEvent)` would + // crash. Route them as regular events — downstream consumers + // (CashuMintDirectoryState, CashuWalletState) already dedupe by (pubKey, dTag) + // and keep the newest. + is CashuMintEvent, + is FedimintEvent, + is MintRecommendationEvent, + is ChatMessageEncryptedFileHeaderEvent, + is ChatMessageEvent, + is BirdDetectionEvent, + is CommentEvent, + // The NIP-29 9xxx moderation actions and join/leave requests are regular + // one-shot events the relay is authoritative for (it applies them and + // republishes the 39000/39001/39002); we store them so they're queryable, + // but we don't act on them client-side. + is PutUserEvent, + is RemoveUserEvent, + is EditMetadataEvent, + is DeleteEventEvent, + is UpdatePinListEvent, + is DeleteGroupEvent, + is CreateGroupEvent, + is CreateInviteEvent, + is JoinRequestEvent, + is LeaveRequestEvent, + is FhirResourceEvent, + is FileHeaderEvent, + is ProfileGalleryEntryEvent, + is FileStorageHeaderEvent, + is GoalEvent, + is GroupEvent, + is GitIssueEvent, + is GitReplyEvent, + is GitPatchEvent, + is GitPullRequestEvent, + is GitPullRequestUpdateEvent, + is GitStatusEvent, + is ChessGameEvent, + is JesterEvent, + is HighlightEvent, + is PodcastEpisodeEvent, + is NIP90StatusEvent, + is NIP90ContentDiscoveryResponseEvent, + is NIP90ContentDiscoveryRequestEvent, + is NIP90UserDiscoveryResponseEvent, + is NIP90UserDiscoveryRequestEvent, + is PictureEvent, + is PrivateDmEvent, + is PublicMessageEvent, + is RequestToVanishEvent, + is CodeSnippetEvent, + is ZapPollEvent, + is RoadEventReportEvent, + is RoadEventConfirmationEvent, + is SealedRumorEvent, + is SoftwareAssetEvent, + is TextNoteEvent, + is TorrentEvent, + is TorrentCommentEvent, + is VideoNormalEvent, + is VideoShortEvent, + is VoiceEvent, + is VoiceReplyEvent, + is WakeUpEvent, + is WelcomeEvent, + is WorkoutRecordEvent, + -> consumeRegularEvent(event, relay, wasVerified) else -> { Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index e7fd4f0d67..51f482ba94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -279,7 +279,7 @@ class AccountNappletGateways( } val result = CompletableDeferred() - 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.")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index 6fa95cfe3f..3f7657ca83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 6fa29d1b5e..0d5ad13cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index 0a70fd4923..82566a44f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index ecfd14b6ad..ddb16c72cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt index 5deaa6928f..7bcd89d292 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -166,7 +166,7 @@ object BlossomPaymentHandler { val preimageResult = CompletableDeferred() 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 78c89dc8e0..6a6085c555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -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? -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index 6e08f8b3e2..67af29c2a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -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(initialValue = null, linkText) { - value = accountViewModel.account.peekConcordInvite(linkText) + value = accountViewModel.account.concord.peekConcordInvite(linkText) } val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index d78cb1919d..a1161ab2b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 77d435e736..f8546909b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4cc64d396a..a4cb62e6a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index e05844f5c5..df3f8c5572 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt index c3e621001b..005e1ba19e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9d372376ed..7963b56c27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, ) = 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, - ) = 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, geohashes: List, ) = 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, - ) = 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, geohashes: List, ) = 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 = 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 42f9e17572..fc7a33d5ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -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 " notification. Welcomes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index a6a5f16670..55fe5a3a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt index 1aa34aaccf..e3f106f595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt index da30fcc4dc..008372414c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt @@ -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) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 0e3427d717..a63b122d6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt index 1930e11780..abd18633d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt index d634500dd6..62ead90720 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt index 31b3145ee9..4dc32ff52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 532b172116..1b40e49820 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index dcd7651d6d..d6a5116be7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -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() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index e92283510e..5d2a043e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 63397ae827..91a1262982 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index d66aef1608..2788dee7ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt index 36dc1e6995..7c287c1241 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 71f86fb4f8..7f308df781 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 5a37ff3e8a..3f57d276b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index f4519f4bcb..ae5275e8f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index a4e1818b7d..a8573a9032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -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}") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt index d9a2fc3da0..629f595cb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 03af2a7865..8bd8d2db7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -268,7 +268,7 @@ class SearchBarViewModel( } if (term.isBlank()) return@combine emptyList() - 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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index f5c2947bc5..51ca681e33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 51db1320fc..c887fd980a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt index cd6e902cfa..6fde099208 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index e988daeed4..15e1150248 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -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, diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 717c519036..f02f086b07 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -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() // 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 = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index d548db09c3..dba6e87238 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -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