From 532b9e67fe5b8bbb08ee19fed1820bb432e9518f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:29:08 +0000 Subject: [PATCH 1/9] refactor: collapse LocalCache event dispatch into grouped when branches justConsumeInnerInner was one when(event) with ~290 branches, of which 172 were identical single-call bodies routing to consumeBaseReplaceable or consumeRegularEvent, and ~55 more were single-line Buzz consumer calls. Since all four shared consumers take a plain Event, the boilerplate branches are now comma-grouped into one branch per consumer (replaceable/addressable, regular, Buzz timeline, Buzz store-only), keeping every branch with per-kind logic exactly as it was. Dispatch is provably unchanged: none of the 289 event classes has a supertype among the classes in any other branch group, so reordering cannot shadow a branch, and the old and new type-to-consumer mappings were compared exhaustively and are identical. The else branch still rejects unlisted kinds, preserving the supported-kinds allowlist. LocalCache.kt: 5155 -> 4554 lines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../amethyst/model/LocalCache.kt | 1257 +++++------------ 1 file changed, 328 insertions(+), 929 deletions(-) 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..e7cdefae15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4072,413 +4072,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 +4114,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 +4129,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 +4161,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 +4183,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 +4279,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 } From 5311efe65ed388b31f5995e02ee0fa64ee25bdde Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:48:21 +0000 Subject: [PATCH 2/9] refactor: extract CachePruner and CacheSearch from LocalCache; move Dao out of ui Two read/reclaim policy clusters leave the LocalCache god object into sibling classes in the same package, each taking the cache as its only constructor dependency so the policies are testable in isolation: - CachePruner: cleanMemory/cleanObservers, the six prune passes (hidden/old/expired/superseded/replies+reactions), and the shared unlinkAndRemove removal primitive (with removeIfWrap and editedTargetIdOf). LocalCache.deleteNote and DecryptAndIndexProcessor now call pruner.unlinkAndRemove; MemoryTrimmingService drives cache.pruner.*. refreshDeletedNoteObservers becomes internal so the pruner can notify observers. - CacheSearch: findUsersStartingWith(username, account), findNotesStartingWith, and the three channel prefix searches, plus their private exclusion rules. Callers (SearchBarViewModel, AgentAttestationScreen, UserSuggestionState, BuzzNewDmViewModel) use cache.search.* directly - no delegating shims left behind. Also moves the Dao interface out of ui/actions/NewMessageTagger.kt into the model package where its implementor (LocalCache) and its types live, removing a model-layer interface defined in a UI file. All moved code is unchanged except for cache. qualification; behavior is identical. LocalCache.kt: 4554 -> 3921 lines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../amethyst/model/CachePruner.kt | 492 +++++++++++++ .../amethyst/model/CacheSearch.kt | 266 +++++++ .../com/vitorpamplona/amethyst/model/Dao.kt | 37 + .../amethyst/model/LocalCache.kt | 661 +----------------- .../eventCache/MemoryTrimmingService.kt | 16 +- .../amethyst/ui/actions/NewMessageTagger.kt | 11 +- .../userSuggestions/UserSuggestionState.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../loggedIn/DecryptAndIndexProcessor.kt | 2 +- .../loggedIn/buzz/AgentAttestationScreen.kt | 5 +- .../loggedIn/buzz/BuzzNewDmViewModel.kt | 2 +- .../loggedIn/search/SearchBarViewModel.kt | 10 +- .../amethyst/NewMessageTaggerKeyParseTest.kt | 2 +- 13 files changed, 832 insertions(+), 676 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt 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/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index e7cdefae15..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. 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/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/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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9d372376ed..49c9ef5be0 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 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..d897298812 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 } 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/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt index 1930e11780..248dbc896f 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 } 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/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 From 7936cab9d5a37fd54ec1288e0fe38c877d936c6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:54:52 +0000 Subject: [PATCH 3/9] refactor: extract EventBroadcaster from Account Moves the sign-and-publish choke point out of Account into an EventBroadcaster class: relay-set computation (outbox model, hints, channel home relays, broadcast lists, DM inboxes, the recursive linked-event descent) plus every publish path (sendAutomatic, sendMyPublicAndPrivateOutbox, sendLiterallyEverywhere, broadcast, signAndSendPrivately*, signAndComputeBroadcast, signAnonymouslyAndBroadcast, republishEventsTo). Account keeps one-line delegates so its 85+ internal call sites and all external callers are unchanged; upcoming Account*Actions extractions will call the broadcaster directly. Moved code is unchanged except for account. qualification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 401 ++-------------- .../amethyst/model/EventBroadcaster.kt | 431 ++++++++++++++++++ 2 files changed, 475 insertions(+), 357 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt 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..af6721ed5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -226,17 +226,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 @@ -274,7 +269,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 @@ -323,12 +317,7 @@ 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 +334,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,7 +353,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 @@ -802,6 +788,13 @@ class Account( // own chat bubbles. val chatDeliveryTracker = ChatDeliveryTracker(client) + /** + * 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) } @@ -1929,239 +1922,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) @@ -3748,14 +3559,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 +3777,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 +4009,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. @@ -5988,14 +5683,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, 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) } + } +} From c0932e033057c5297d818c6dd380e1ea41adf340 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:05:00 +0000 Subject: [PATCH 4/9] refactor: extract AccountConcordActions from Account Moves the ~1,000-line Concord orchestration cluster (join/create/invite flows, channel messages/reactions/edits/typing, roles and moderation, refound/rekey/stranded-recovery, metadata + channel management, control-plane sync) into AccountConcordActions, exposed as account.concord. The two Concord file-level constants move with it. Rumor ingestion (consumeConcordRumorGated, refreshConcordChannelIndex) stays on Account since ConcordSessionManager is constructed with it, as do the cross-feature sendMinichatReply and the read-path isConcordBanned policy. External callers (Concord screens, AccountViewModel forwarders, note action menus) now call account.concord.* directly - no delegating shims. Moved code is unchanged except for account. qualification. Account.kt: 6228 -> 4935 lines so far in this series. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 992 +-------------- .../amethyst/model/AccountConcordActions.kt | 1065 +++++++++++++++++ .../ui/components/ConcordInviteCard.kt | 2 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 4 +- .../ui/note/elements/NoteActionSections.kt | 4 +- .../ui/screen/loggedIn/AccountViewModel.kt | 24 +- .../concord/ConcordChannelListScreen.kt | 8 +- .../concord/ConcordChannelScreen.kt | 2 +- .../concord/ConcordCreateScreen.kt | 2 +- .../concord/ConcordEditScreen.kt | 2 +- .../concord/ConcordInviteScreen.kt | 2 +- .../datasource/ConcordChannelPreviewLoader.kt | 4 +- .../datasource/ConcordChannelSubscription.kt | 4 +- .../send/ConcordNewMessageViewModel.kt | 4 +- 14 files changed, 1102 insertions(+), 1017 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt 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 af6721ed5f..0508c6e857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -25,9 +25,6 @@ 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 @@ -163,7 +160,6 @@ 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 @@ -184,21 +180,8 @@ 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 @@ -228,14 +211,10 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Address 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.EventHintBundle 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 @@ -402,24 +381,12 @@ 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( @@ -788,6 +755,9 @@ class Account( // own chat bubbles. val chatDeliveryTracker = ChatDeliveryTracker(client) + /** Concord community orchestration (join/create/messages/moderation). */ + val concord = AccountConcordActions(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 @@ -1994,245 +1964,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 @@ -2248,7 +1979,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, @@ -2347,717 +2078,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, @@ -5855,10 +4875,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..4b65c4848f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -0,0 +1,1065 @@ +/* + * 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)") + } + + // ── 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. */ +} 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/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/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 49c9ef5be0..70079e66bb 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 @@ -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,7 +691,7 @@ class AccountViewModel( communityId: String, channelIdHex: String, ) = viewModelScope.launch(Dispatchers.IO) { - account.sendConcordTyping(communityId, channelIdHex) + account.concord.sendConcordTyping(communityId, channelIdHex) } fun sendBuzzTyping(channel: RelayGroupChannel) = @@ -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, 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() From 20149e26006b87f28424de742dd7b73dfb93c354 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:07:24 +0000 Subject: [PATCH 5/9] refactor: extract AccountMarmotActions from Account Moves the ~540-line Marmot/MLS orchestration cluster (group create/ leave/reset, member add/remove via key-package fetch, admin grant/ revoke, metadata updates, group messaging, key-package publishing and relay resolution) into AccountMarmotActions, exposed as account.marmot. External callers (marmot group screens, AccountViewModel forwarders, NotificationReplyReceiver, DecryptAndIndexProcessor) now call account.marmot.* directly. Moved code is unchanged except for account. qualification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 540 +--------------- .../amethyst/model/AccountMarmotActions.kt | 580 ++++++++++++++++++ .../NotificationReplyReceiver.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 38 +- .../loggedIn/DecryptAndIndexProcessor.kt | 2 +- 5 files changed, 605 insertions(+), 557 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt 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 0508c6e857..b86ecdf03d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -758,6 +758,9 @@ class Account( /** 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) + /** * Relay routing + sign-and-publish choke point: computes which relays an event * should go to (outbox model, hints, channels, broadcast lists) and owns every @@ -3521,541 +3524,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) { @@ -4811,7 +4279,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. 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..43029dfbc0 --- /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: account.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: account.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/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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 70079e66bb..7a9981040a 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 @@ -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() { 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 d897298812..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 @@ -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 From d2c9593919be2e50fd2239bb9db63f57833cd99c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:11:04 +0000 Subject: [PATCH 6/9] refactor: extract AccountRelayGroupActions from Account Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration (join/leave/create/delete/archive, threads, invites, pins, member/role management, metadata edits, Buzz DMs/jobs/workflows/typing, community member add/remove) into AccountRelayGroupActions, exposed as account.relayGroups. External callers now use account.relayGroups.* directly. Moved code is unchanged except for account. qualification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 506 +--------------- .../model/AccountRelayGroupActions.kt | 549 ++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 36 +- .../loggedIn/buzz/AgentWorkBoardViewModel.kt | 12 +- .../screen/loggedIn/buzz/BuzzDmListScreen.kt | 2 +- .../loggedIn/buzz/BuzzDmListViewModel.kt | 4 +- .../loggedIn/buzz/BuzzNewDmViewModel.kt | 2 +- .../screen/loggedIn/buzz/JobBoardViewModel.kt | 6 +- .../buzz/WorkflowRunBoardViewModel.kt | 8 +- .../relayGroup/RelayGroupMetadataViewModel.kt | 4 +- .../send/ChannelNewMessageViewModel.kt | 2 +- 11 files changed, 590 insertions(+), 541 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt 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 b86ecdf03d..d9c5c66860 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -34,7 +34,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 @@ -47,10 +46,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 @@ -160,26 +157,9 @@ 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.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.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent @@ -214,10 +194,7 @@ 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.client.INostrClient -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.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 @@ -268,20 +245,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 @@ -332,7 +297,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.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -378,8 +342,6 @@ 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 kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash @@ -761,6 +723,9 @@ class Account( /** 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) + /** * Relay routing + sign-and-publish choke point: computes which relays an event * should go to (outbox model, hints, channels, broadcast lists) and owns every @@ -2081,471 +2046,6 @@ class Account( return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") } - 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)) 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..7816501a0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -0,0 +1,549 @@ +/* + * 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, +) { + 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/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7a9981040a..3a24635536 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 @@ -696,7 +696,7 @@ class AccountViewModel( fun sendBuzzTyping(channel: RelayGroupChannel) = viewModelScope.launch(Dispatchers.IO) { - account.sendBuzzTyping(channel) + account.relayGroups.sendBuzzTyping(channel) } @Immutable @@ -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) } @@ -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, 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 248dbc896f..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 @@ -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/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}") From 1dba215d4dea50d6ed3c0924564db08be0c93734 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:16:27 +0000 Subject: [PATCH 7/9] refactor: extract AccountZapActions from Account Moves the ~270-line zap/payment orchestration (NIP-57 zap requests, NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC onchain zaps/sends/splits) into AccountZapActions, exposed as account.zaps. The onchain backend-not-configured constant moves with it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet viewmodels, blossom payments, app functions) now call account.zaps.* directly. Moved code is unchanged except for account. qualification. Completes the Account decoupling series: Account.kt went from 6228 to 3618 lines across EventBroadcaster, AccountConcordActions, AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 291 +-------------- .../amethyst/model/AccountZapActions.kt | 337 ++++++++++++++++++ .../gateways/AccountNappletGateways.kt | 2 +- .../amethyst/service/V4VPaymentHandler.kt | 6 +- .../amethyst/service/ZapPaymentHandler.kt | 8 +- .../uploads/blossom/BlossomPaymentHandler.kt | 2 +- .../amethyst/ui/note/ZapPollNoteViewModel.kt | 2 +- .../amethyst/ui/note/types/Goal.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 14 +- .../profile/payment/SendPaymentScreen.kt | 4 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 4 +- .../loggedIn/wallet/ReloadMintViewModel.kt | 2 +- .../loggedIn/wallet/TopUpMintViewModel.kt | 2 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 20 +- .../appfunctions/AmethystAppFunctions.kt | 4 +- 15 files changed, 376 insertions(+), 324 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt 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 d9c5c66860..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,7 +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.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore @@ -60,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 @@ -254,12 +248,6 @@ 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.tags.AddressBookmark import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -319,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 @@ -347,8 +333,6 @@ 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" - @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -726,6 +710,9 @@ class Account( /** 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 @@ -1339,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, 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/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/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/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/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 3a24635536..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 @@ -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 -> @@ -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/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/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 = From dc5e4562fdbcd0c57046bc1353e518d8335c761d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:22:31 +0000 Subject: [PATCH 8/9] fix: restore two Marmot log messages mangled during extraction The account-qualification regex in the AccountMarmotActions extraction also rewrote 'marmotManager is NULL' to 'account.marmotManager is NULL' inside two log string literals, changing log output text. Restore the original wording. Found by the post-refactor equivalence audit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../com/vitorpamplona/amethyst/model/AccountMarmotActions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 43029dfbc0..e45fd03764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -278,7 +278,7 @@ class AccountMarmotActions( suspend fun publishMarmotKeyPackages() { val manager = account.marmotManager ?: run { - Log.w("MarmotDbg") { "publishMarmotKeyPackages: account.marmotManager is NULL — no-op" } + Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" } return } if (!account.isWriteable()) { @@ -475,7 +475,7 @@ class AccountMarmotActions( } val manager = account.marmotManager ?: run { - Log.w("MarmotDbg") { "removeMarmotGroupMember: account.marmotManager is NULL — no-op" } + Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" } return } if (!account.isWriteable()) { From 92ca11a583f65d6762150abe25de19eaec63f6ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:27:56 +0000 Subject: [PATCH 9/9] chore: move stray NIP-29 section comment to AccountRelayGroupActions The relay-group section header and joinRelayGroup KDoc were left dangling at the end of AccountConcordActions when the clusters were split into separate files; reattach them to the function they describe. Found by the post-refactor audit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/AccountConcordActions.kt | 7 ------- .../amethyst/model/AccountRelayGroupActions.kt | 5 +++++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 4b65c4848f..6fc115f11d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -1055,11 +1055,4 @@ class AccountConcordActions( account.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. */ } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt index 7816501a0b..d8be5a68c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -82,6 +82,11 @@ import kotlinx.serialization.json.Json 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,