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 350736804..15d33c4fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -379,6 +379,10 @@ class Account( val blossomServers = BlossomServerListState(signer, cache, scope, settings) + val nestsServers = + com.vitorpamplona.amethyst.model.nip53NestsServers + .NestsServerListState(signer, cache, scope) + // Relay settings val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope) val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope) @@ -3001,6 +3005,7 @@ class Account( bookmarkState.getBookmarkList(), pinState.getPinList(), blossomServers.getBlossomServersList(), + nestsServers.getNestsServersList(), paymentTargetsState.getPaymentTargetsEvent(), trustProviderList.getTrustProviderList(), cache.getAddressableNoteIfExists(appSpecific.getAppSpecificDataAddress())?.event, @@ -3069,6 +3074,8 @@ class Account( suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) + suspend fun sendNestsServersList(servers: List) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers)) + suspend fun savePaymentTargets(targets: List) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets)) fun markAsRead( 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 8858af67c..624edb63d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2677,157 +2677,613 @@ object LocalCache : ILocalCache, ICacheProvider { ): 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 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) - 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 ClassifiedsEvent -> 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 EphemeralChatListEvent -> consumeBaseReplaceable(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 GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified) - 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 GitRepositoryEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is RootSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is NamedSiteEvent -> 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 -> consumeBaseReplaceable(event, relay, wasVerified) - is LnZapEvent -> consume(event, relay, wasVerified) - is LnZapRequestEvent -> 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 ChatEvent -> consumeRegularEvent(event, relay, wasVerified) - is PollEvent -> consumeRegularEvent(event, relay, wasVerified) - is PollResponseEvent -> consume(event, relay, wasVerified) - is RelayDiscoveryEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is RelayMonitorEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is ReactionEvent -> 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 SealedRumorEvent -> consumeRegularEvent(event, relay, wasVerified) - is SearchRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is StatusEvent -> consume(event, relay, wasVerified) - is TextNoteEvent -> consumeRegularEvent(event, relay, wasVerified) - is TextNoteModificationEvent -> 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 PaymentTargetsEvent -> consume(event, relay, wasVerified) - else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } + 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 com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.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) + } + + 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 ClassifiedsEvent -> { + 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 EphemeralChatListEvent -> { + consumeBaseReplaceable(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 GiftWrapEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + 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 GitRepositoryEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is RootSiteEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is NamedSiteEvent -> { + 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 -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is LnZapEvent -> { + consume(event, relay, wasVerified) + } + + is LnZapRequestEvent -> { + 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 ChatEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is PollEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is PollResponseEvent -> { + consume(event, relay, wasVerified) + } + + is RelayDiscoveryEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is RelayMonitorEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is ReactionEvent -> { + 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 SealedRumorEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is SearchRelayListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is StatusEvent -> { + consume(event, relay, wasVerified) + } + + is TextNoteEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is TextNoteModificationEvent -> { + 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 PaymentTargetsEvent -> { + consume(event, relay, wasVerified) + } + + else -> { + Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } + } } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53NestsServers/NestsServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53NestsServers/NestsServerListState.kt new file mode 100644 index 000000000..19bd00534 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip53NestsServers/NestsServerListState.kt @@ -0,0 +1,100 @@ +/* + * 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.nip53NestsServers + +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * Per-account state for the user's preferred audio-room (NIP-53 / nests) + * MoQ host servers — kind 10062 [NestsServersEvent]. Mirror of + * [com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState] + * for the nests use case. + * + * Surfaces: + * - [flow] — current `List` of saved server base URLs + * - [getNestsServersListFlow] — reactive `StateFlow` for + * downstream UI to recompose on event arrivals + * - [saveNestsServersList] — build + sign a new replaceable kind 10062 + * event (preserving prior tags' alt etc.) + * + * The list is consumed by [com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.create.CreateAudioRoomViewModel] + * to default the "MoQ service URL" / "MoQ endpoint URL" fields when + * starting a new space, and by the Settings screen for edit / add / + * remove. + */ +class NestsServerListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + /** Long-term reference to keep the addressable note from being GC'd. */ + val nestsListNote = cache.getOrCreateAddressableNote(getNestsServersAddress()) + + fun getNestsServersAddress() = NestsServersEvent.createAddress(signer.pubKey) + + fun getNestsServersListFlow(): StateFlow = nestsListNote.flow().metadata.stateFlow + + fun getNestsServersList(): NestsServersEvent? = nestsListNote.event as? NestsServersEvent + + fun normalizeServers(note: Note): List { + val event = note.event as? NestsServersEvent + return event?.servers() ?: emptyList() + } + + val flow: StateFlow> = + getNestsServersListFlow() + .map { + normalizeServers(it.note) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun saveNestsServersList(servers: List): NestsServersEvent { + val serverList = getNestsServersList() + + return if (serverList != null && serverList.tags.isNotEmpty()) { + NestsServersEvent.updateRelayList( + earlierVersion = serverList, + servers = servers, + signer = signer, + ) + } else { + NestsServersEvent.createFromScratch( + relays = servers, + signer = signer, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 6e336cb68..38addb9f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -58,6 +58,7 @@ val AccountInfoAndListsFromKeyKinds = SearchRelayListEvent.KIND, FileServersEvent.KIND, BlossomServersEvent.KIND, + com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND, PrivateOutboxRelayListEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 41e08a87b..a3434dbee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -52,6 +52,7 @@ val BasicAccountInfoKinds = SearchRelayListEvent.KIND, FileServersEvent.KIND, BlossomServersEvent.KIND, + com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND, ) val BasicAccountInfoKinds2 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt new file mode 100644 index 000000000..f9929e674 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersScreen.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.nestsServers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServerEditField +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText + +/** + * Settings screen for the user's preferred audio-room (NIP-53 / nests) + * MoQ host servers — kind 10062 [com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent]. + * + * Mirrors [com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen] + * — top bar with Cancel / Save, list of saved servers with delete + * buttons, edit-field row to add a new one, and a "Use defaults" row + * for one-tap inclusion of `nostrnests.com`. + * + * Reachable from `Settings → Audio-room servers`. + */ +@Composable +fun NestsServersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: NestsServersViewModel = viewModel() + viewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel) { viewModel.load() } + + NestsServersScaffold(viewModel) { nav.popBack() } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NestsServersScaffold( + viewModel: NestsServersViewModel, + onClose: () -> Unit, +) { + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.nests_servers_title, + onCancel = { + viewModel.refresh() + onClose() + }, + onPost = { + viewModel.save() + onClose() + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringRes(id = R.string.nests_servers_explainer), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.grayText, + ) + NestsServersBody(viewModel) + } + } +} + +@Composable +private fun NestsServersBody(viewModel: NestsServersViewModel) { + val servers by viewModel.servers.collectAsStateWithLifecycle() + + LazyColumn( + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = FeedPadding, + ) { + item { + SettingsCategory( + R.string.nests_servers_my_section, + R.string.nests_servers_my_explainer, + SettingsCategoryFirstModifier, + ) + } + + if (servers.isEmpty()) { + item { + Text( + text = stringRes(id = R.string.nests_servers_empty), + modifier = DoubleVertPadding, + ) + } + } else { + itemsIndexed(servers, key = { _, s -> "saved-${s.baseUrl}" }) { _, entry -> + NestsServerEntry( + server = entry, + isAmethystDefault = false, + onAction = { viewModel.removeServer(entry.baseUrl) }, + ) + } + } + + item { + Spacer(modifier = StdVertSpacer) + MediaServerEditField(label = R.string.nests_servers_add_field) { url -> + viewModel.addServer(url) + } + } + + item { + SettingsCategoryWithButton( + title = R.string.nests_servers_recommended_section, + description = R.string.nests_servers_recommended_explainer, + modifier = SettingsCategorySpacingModifier, + ) { + OutlinedButton( + onClick = { viewModel.addServerList(DEFAULT_NESTS_SERVERS.map { it.baseUrl }) }, + ) { + Text(text = stringRes(id = R.string.nests_servers_use_defaults)) + } + } + } + + itemsIndexed(DEFAULT_NESTS_SERVERS, key = { _, s -> "proposed-${s.baseUrl}" }) { _, entry -> + NestsServerEntry( + server = entry, + isAmethystDefault = true, + onAction = { viewModel.addServer(entry.baseUrl) }, + ) + } + } +} + +@Composable +private fun NestsServerEntry( + server: NestsServer, + isAmethystDefault: Boolean, + onAction: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = StdVertSpacer) + Text( + text = server.baseUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + Row(horizontalArrangement = Arrangement.End) { + IconButton(onClick = onAction) { + Icon( + symbol = if (isAmethystDefault) MaterialSymbols.Add else MaterialSymbols.Delete, + contentDescription = + if (isAmethystDefault) { + stringRes(id = R.string.nests_servers_add_recommended) + } else { + stringRes(id = R.string.nests_servers_remove) + }, + ) + } + } + } +} + +/** + * Built-in suggestion list shown under "Recommended servers". Today + * just the public `nostrnests.com` deployment; add new entries here + * as community-run moq-rs / moq-auth instances come online. + */ +val DEFAULT_NESTS_SERVERS: List = + listOf( + NestsServer(name = "nostrnests.com", baseUrl = "https://moq.nostrnests.com"), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt new file mode 100644 index 000000000..b7bdd18b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/NestsServersViewModel.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.nestsServers + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.utils.Rfc3986 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Edit-buffer for the user's audio-room (NIP-53 / nests) MoQ server + * list. Mirror of [com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomServersViewModel] + * scoped down to a plain `List` of base URLs since nests + * servers don't have a "selected default" concept (clients pick the + * first entry when starting a new space). + * + * The flow: + * 1. [init] binds the [AccountViewModel]. + * 2. [load] (or [refresh]) snapshots the saved list into the local + * [_servers] buffer. + * 3. [addServer] / [removeServer] / [removeAllServers] mutate the + * buffer, marking [isModified] = true. + * 4. [save] publishes a fresh kind-10062 [com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent] + * via [Account.sendNestsServersList]. + */ +@Stable +class NestsServersViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + + private val _servers = MutableStateFlow>(emptyList()) + val servers = _servers.asStateFlow() + private var isModified = false + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun load() = refresh() + + fun refresh() { + isModified = false + val current = account.nestsServers.flow.value + _servers.update { + current.map { url -> NestsServer(displayHostName(url), url) } + } + } + + fun addServer(url: String) { + val normalised = + try { + Rfc3986.normalize(url.trim()) + } catch (_: Throwable) { + url.trim() + } + if (normalised.isBlank()) return + val entry = NestsServer(displayHostName(normalised), normalised) + if (_servers.value.any { it.baseUrl == entry.baseUrl }) return + _servers.update { it + entry } + isModified = true + } + + fun addServerList(urls: List) { + urls.forEach { addServer(it) } + } + + fun removeServer(baseUrl: String) { + val before = _servers.value + val after = before.filterNot { it.baseUrl == baseUrl } + if (after.size != before.size) { + _servers.update { after } + isModified = true + } + } + + fun removeAllServers() { + if (_servers.value.isNotEmpty()) { + _servers.update { emptyList() } + isModified = true + } + } + + fun save() { + if (!isModified) return + accountViewModel.launchSigner { + account.sendNestsServersList(_servers.value.map { it.baseUrl }) + refresh() + } + } + + private fun displayHostName(url: String): String = + try { + Rfc3986.host(url).removePrefix("moq.").removePrefix("nests.") + } catch (_: Throwable) { + url + } +} + +/** + * Display-friendly entry for [NestsServersViewModel]. + * - [name] — short host label (e.g. `nostrnests.com`) + * - [baseUrl] — exact bytes that go on the wire (`https://moq.nostrnests.com`) + */ +data class NestsServer( + val name: String, + val baseUrl: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 424cd640d..85268ee02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -319,6 +319,10 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEndArgs { + com.vitorpamplona.amethyst.ui.actions.nestsServers + .NestsServersScreen(accountViewModel, nav) + } composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index c93cddace..32371da62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -251,6 +251,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object EditNestsServers : Route() + @Serializable object EditFavoriteAlgoFeeds : Route() @Serializable object EditPaymentTargets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt index c08199cff..cb6414157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt @@ -20,10 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState @@ -35,7 +43,9 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.create.CreateAudioRoomSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.AudioRoomsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes @Composable fun AudioRoomsScreen( @@ -59,6 +69,8 @@ fun AudioRoomsScreen( WatchAccountForAudioRoomsScreen(audioRoomsFeedState = audioRoomsFeedContentState, accountViewModel = accountViewModel) AudioRoomsFilterAssemblerSubscription(accountViewModel) + var showCreateSheet by remember { mutableStateOf(false) } + DisappearingScaffold( isInvertedLayout = false, topBar = { @@ -73,6 +85,17 @@ fun AudioRoomsScreen( } } }, + floatingButton = { + FloatingActionButton( + onClick = { showCreateSheet = true }, + shape = CircleShape, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.audio_room_create_fab), + ) + } + }, accountViewModel = accountViewModel, ) { RefresheableBox(audioRoomsFeedContentState, true) { @@ -95,6 +118,13 @@ fun AudioRoomsScreen( } } } + + if (showCreateSheet) { + CreateAudioRoomSheet( + accountViewModel = accountViewModel, + onDismiss = { showCreateSheet = false }, + ) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt new file mode 100644 index 000000000..46da363bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomSheet.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.create + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomBridge +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** + * Bottom sheet that lets the logged-in user start a new NIP-53 kind 30312 + * audio room. On submit, [CreateAudioRoomViewModel] builds and signs the + * MeetingSpaceEvent (with the user as `host`), broadcasts it to the + * account's relays, and then launches [AudioRoomActivity] against the + * fresh address — the user lands inside the room as host with the + * Talk button enabled. + * + * Hidden by default; surfaced by the "Start space" FAB on + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.AudioRoomsScreen]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateAudioRoomSheet( + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val viewModelKey = remember(accountViewModel) { accountViewModel.account.userProfile().pubkeyHex } + val viewModel: CreateAudioRoomViewModel = + viewModel(key = "CreateAudioRoom-$viewModelKey") + LaunchedEffect(viewModel) { viewModel.bindAccountIfMissing(accountViewModel) } + + val state by viewModel.state.collectAsState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val context = LocalContext.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.audio_room_create_title), + style = MaterialTheme.typography.titleLarge, + ) + + OutlinedTextField( + value = state.roomName, + onValueChange = viewModel::onRoomNameChange, + label = { Text(stringRes(R.string.audio_room_create_field_room)) }, + singleLine = true, + isError = state.error != null && state.roomName.isBlank(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = state.summary, + onValueChange = viewModel::onSummaryChange, + label = { Text(stringRes(R.string.audio_room_create_field_summary)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + OutlinedTextField( + value = state.serviceUrl, + onValueChange = viewModel::onServiceUrlChange, + label = { Text(stringRes(R.string.audio_room_create_field_service)) }, + singleLine = true, + isError = state.error != null && state.serviceUrl.isBlank(), + supportingText = { Text(stringRes(R.string.audio_room_create_field_service_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = state.endpointUrl, + onValueChange = viewModel::onEndpointUrlChange, + label = { Text(stringRes(R.string.audio_room_create_field_endpoint)) }, + singleLine = true, + isError = state.error != null && state.endpointUrl.isBlank(), + supportingText = { Text(stringRes(R.string.audio_room_create_field_endpoint_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = state.imageUrl, + onValueChange = viewModel::onImageUrlChange, + label = { Text(stringRes(R.string.audio_room_create_field_image)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + state.error?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss, enabled = !state.isPublishing) { + Text(stringRes(R.string.audio_room_create_cancel)) + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + scope.launch { + val launchInfo = viewModel.publishAndBuildLaunchInfo() ?: return@launch + // Hand the active AccountViewModel to the + // separately-tasked AudioRoomActivity (mirrors + // the join-card flow). + AudioRoomBridge.set(accountViewModel) + AudioRoomActivity.launch( + context = context, + addressValue = launchInfo.addressValue, + authBaseUrl = launchInfo.authBaseUrl, + endpoint = launchInfo.endpoint, + hostPubkey = launchInfo.hostPubkey, + roomId = launchInfo.roomId, + kind = launchInfo.kind, + ) + onDismiss() + } + }, + enabled = !state.isPublishing && state.canSubmit, + ) { + if (state.isPublishing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Text(stringRes(R.string.audio_room_create_submit)) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt new file mode 100644 index 000000000..c03ff81ce --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/CreateAudioRoomViewModel.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.create + +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.endpoint +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.image +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Backing ViewModel for [CreateAudioRoomSheet]. Holds form state, runs + * [publishAndBuildLaunchInfo] which signs and broadcasts a NIP-53 kind + * 30312 [MeetingSpaceEvent] tagging the user as `host`, then returns + * the launch parameters for [com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomActivity]. + * + * The defaults point at `nostrnests.com`'s public moq-rs deployment so a + * blank form produces a working room. The user can edit them to point at + * their own moq-rs / moq-auth pair. + */ +class CreateAudioRoomViewModel : ViewModel() { + /** Lazily bound on first composition; the sheet calls [bindAccountIfMissing]. */ + @Volatile private var account: AccountViewModel? = null + + private val _state = MutableStateFlow(FormState.defaults()) + val state: StateFlow = _state.asStateFlow() + + fun bindAccountIfMissing(accountViewModel: AccountViewModel) { + if (account != null) return + account = accountViewModel + // Seed the URL fields from the user's saved kind-10062 list so + // first-time use flows naturally from the Settings screen. If + // the list is empty (or the user hasn't published one yet), keep + // the nostrnests.com defaults already in [FormState.defaults]. + val savedFirst = + accountViewModel.account.nestsServers.flow.value + .firstOrNull() + ?.takeIf { it.startsWith("http") } + if (savedFirst != null) { + _state.update { it.copy(serviceUrl = savedFirst, endpointUrl = savedFirst) } + } + } + + fun onRoomNameChange(value: String) = _state.update { it.copy(roomName = value, error = null) } + + fun onSummaryChange(value: String) = _state.update { it.copy(summary = value, error = null) } + + fun onServiceUrlChange(value: String) = _state.update { it.copy(serviceUrl = value.trim(), error = null) } + + fun onEndpointUrlChange(value: String) = _state.update { it.copy(endpointUrl = value.trim(), error = null) } + + fun onImageUrlChange(value: String) = _state.update { it.copy(imageUrl = value.trim(), error = null) } + + /** + * Build the kind-30312 event, sign + broadcast it, and return the + * launch info the sheet needs to start [AudioRoomActivity]. Returns + * null on validation or network failure (with [FormState.error] + * set so the UI can render it). + */ + suspend fun publishAndBuildLaunchInfo(): RoomLaunchInfo? { + val current = _state.value + val account = + account ?: run { + _state.update { it.copy(error = "Account not bound; please retry from the spaces screen.") } + return null + } + if (current.roomName.isBlank()) { + _state.update { it.copy(error = "Room name is required.") } + return null + } + val service = current.serviceUrl.trim().trimEnd('/') + val endpoint = current.endpointUrl.trim().trimEnd('/') + if (service.isBlank() || !service.startsWith("http")) { + _state.update { it.copy(error = "MoQ service URL must start with http(s)://") } + return null + } + if (endpoint.isBlank() || !endpoint.startsWith("http")) { + _state.update { it.copy(error = "MoQ endpoint URL must start with http(s)://") } + return null + } + + _state.update { it.copy(isPublishing = true, error = null) } + val accountModel = account.account + val hostPubkey = accountModel.userProfile().pubkeyHex + val template = + MeetingSpaceEvent.build( + room = current.roomName.trim(), + status = StatusTag.STATUS.OPEN, + service = service, + host = ParticipantTag(hostPubkey, null, ROLE.HOST.code, null), + ) { + endpoint(endpoint) + current.summary + .trim() + .takeIf { it.isNotBlank() } + ?.let { summary(it) } + current.imageUrl + .trim() + .takeIf { it.isNotBlank() } + ?.let { image(it) } + } + + val signed = + try { + accountModel.signAndComputeBroadcast(template) + } catch (t: Throwable) { + _state.update { + it.copy( + isPublishing = false, + error = "Failed to publish: ${t.message ?: t::class.simpleName}", + ) + } + return null + } + + // Don't reset isPublishing here — the sheet dismisses on success + // and the VM is scoped per-composition so the next open starts + // fresh anyway. + return RoomLaunchInfo( + addressValue = signed.address().toValue(), + authBaseUrl = service, + endpoint = endpoint, + hostPubkey = hostPubkey, + roomId = signed.dTag(), + kind = MeetingSpaceEvent.KIND, + ) + } + + data class FormState( + val roomName: String, + val summary: String, + val serviceUrl: String, + val endpointUrl: String, + val imageUrl: String, + val isPublishing: Boolean, + val error: String?, + ) { + val canSubmit: Boolean + get() = roomName.isNotBlank() && serviceUrl.isNotBlank() && endpointUrl.isNotBlank() + + companion object { + fun defaults() = + FormState( + roomName = "", + summary = "", + serviceUrl = DEFAULT_SERVICE_URL, + endpointUrl = DEFAULT_ENDPOINT_URL, + imageUrl = "", + isPublishing = false, + error = null, + ) + } + } + + /** + * Captured fields needed to launch [AudioRoomActivity]. Mirrors the + * `EXTRA_*` set the activity expects; pulled out here so the sheet + * is a thin renderer. + */ + data class RoomLaunchInfo( + val addressValue: String, + val authBaseUrl: String, + val endpoint: String, + val hostPubkey: String, + val roomId: String, + val kind: Int, + ) + + companion object { + /** + * Public nostrnests deployment — a blank form produces a working + * room here. Users can edit either field to point at their own + * moq-auth / moq-relay pair. + */ + const val DEFAULT_SERVICE_URL: String = "https://moq.nostrnests.com" + const val DEFAULT_ENDPOINT_URL: String = "https://moq.nostrnests.com" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index f1258d8ce..667817545 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -543,6 +543,7 @@ fun kindDisplayName(kind: Int): Int = BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays BlossomServersEvent.KIND -> R.string.kind_blossom_servers + com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND -> R.string.kind_nests_servers BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays BookmarkListEvent.KIND -> R.string.kind_bookmark_list diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 93fc957bd..bfaa3d2d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -127,6 +127,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.EditMediaServers) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.nests_servers_title, + icon = MaterialSymbols.CloudUpload, + tint = tint, + onClick = { nav.nav(Route.EditNestsServers) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.profile_badges_title, icon = MaterialSymbols.MilitaryTech, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b432454c8..b9fc0bfb1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -521,6 +521,28 @@ Stop Join audio room Leave + Start space + Start a new audio room + Room name + What\'s it about? + MoQ service URL + Auth sidecar — defaults to nostrnests.com + MoQ relay endpoint + WebTransport URL — usually the same host + Cover image URL (optional) + Cancel + Start space + Audio-room servers + Choose which MoQ host servers Amethyst publishes your audio rooms to. The first entry is used by default when you start a new space. + Your servers + Saved as a kind-10062 replaceable event so other clients can read your preference. + Add an audio-room server + Recommended servers + Built-in suggestions you can add to your list with one tap. + Use Amethyst defaults + Add server + Remove server + No audio-room servers yet. Add one below or pick a recommended server. Videos Articles Private Bookmarks @@ -2071,6 +2093,7 @@ Profile Badges Blocked Relays Blossom Servers + Audio-room (Nests) Servers Blossom Auth Broadcast Relays Bookmark List diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt new file mode 100644 index 000000000..cbd1fd7d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/nestsServers/NestsServersEvent.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.nestsServers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Replaceable event listing the audio-room (NIP-53 / nests) MoQ host + * servers a user prefers to publish their kind-30312 spaces against. + * + * Kind: **10062** (proposed; mirrors BlossomServersEvent's 10063 layout). + * + * Wire shape — one `server` tag per host base URL: + * + * { + * "kind": 10062, + * "tags": [ + * ["alt", "Audio-room (nests) MoQ servers used by the author"], + * ["server", "https://moq.nostrnests.com"], + * ["server", "https://moq.example.org"], + * ... + * ], + * "content": "" + * } + * + * The `server` URL points at the moq-rs / moq-auth deployment's base + * URL — Amethyst's `CreateAudioRoomSheet` uses it for both the + * `service` (auth sidecar) and `endpoint` (WebTransport relay) tags + * on the kind-30312 event, since nostrnests's reference deployment + * co-locates them. A future revision can split the two into separate + * tag fields if the community pulls them apart. + * + * This event is shaped 1:1 after `BlossomServersEvent` (kind 10063, + * NIP-B7) so existing list-state / settings UI patterns translate + * directly. + */ +@Immutable +class NestsServersEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun servers(): List = + tags.mapNotNull { + if (it.size > 1 && it[0] == "server") { + it[1] + } else { + null + } + } + + companion object { + const val KIND = 10062 + const val ALT = "Audio-room (nests) MoQ servers used by the author" + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun createTagArray(servers: List): Array> = + servers + .map { arrayOf("server", it) } + .plusElement(AltTag.assemble(ALT)) + .toTypedArray() + + suspend fun updateRelayList( + earlierVersion: NestsServersEvent, + servers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): NestsServersEvent { + val tags = + earlierVersion.tags + .filter { it[0] != "server" } + .plus(servers.map { arrayOf("server", it) }) + .toTypedArray() + + return signer.sign(createdAt, KIND, tags, earlierVersion.content) + } + + suspend fun createFromScratch( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = create(relays, signer, createdAt) + + suspend fun create( + servers: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = signer.sign(createdAt, KIND, createTagArray(servers), "") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c22b0e799..4493df89d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -168,6 +168,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -332,6 +333,7 @@ class EventFactory { BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig) BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig) BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig) + NestsServersEvent.KIND -> NestsServersEvent(id, pubKey, createdAt, tags, content, sig) BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) BroadcastRelayListEvent.KIND -> BroadcastRelayListEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)