feat(audio-rooms): start space FAB + Nests servers settings (kind 10062)

Two adjacent additions so users can both create and host their own
audio rooms from inside Amethyst:

Create-space flow
  - CreateAudioRoomSheet — modal bottom sheet on AudioRoomsScreen
    surfaced by a new "Start space" FAB. Fields: room name, summary,
    MoQ service URL, MoQ relay endpoint, optional cover image.
  - CreateAudioRoomViewModel — builds + signs MeetingSpaceEvent
    (kind 30312, status=OPEN, tagging the user as `host`),
    broadcasts via account.signAndComputeBroadcast, then returns
    launch info so the sheet can fire AudioRoomActivity straight
    into the freshly-published room.
  - Defaults pull the first saved Nests server (below) when present;
    fall back to https://moq.nostrnests.com.

Nests servers settings (proposed kind 10062)
  - NestsServersEvent — replaceable kind-10062 event listing the
    user's preferred audio-room MoQ servers. Wire shape mirrors
    BlossomServersEvent (one `server` tag per base URL); registered
    in EventFactory; consumed by LocalCache via consumeBaseReplaceable.
  - NestsServerListState — per-account observation state, mirror of
    BlossomServerListState, exposed on Account.nestsServers.
  - sendNestsServersList on Account; included in
    accountSettingsEvents() so an outbox change republishes it.
  - Filter additions: BasicAccountInfo + AccountInfoAndLists now
    request kind 10062 from relays.
  - NestsServersViewModel + NestsServersScreen — Settings UI to
    add / remove / reset to recommended servers (currently just
    nostrnests.com). Wired into AllSettingsScreen as "Audio-room
    servers"; routed via Route.EditNestsServers.
  - kind_nests_servers label for RelayInformationScreen.

Default suggestion list lives in DEFAULT_NESTS_SERVERS at the top
of NestsServersScreen — add new community-run moq-rs deployments
there as they come online.
This commit is contained in:
Claude
2026-04-26 18:53:31 +00:00
parent 76b772ab41
commit 364b2cd926
17 changed files with 1691 additions and 151 deletions
@@ -379,6 +379,10 @@ class Account(
val blossomServers = BlossomServerListState(signer, cache, scope, settings) val blossomServers = BlossomServerListState(signer, cache, scope, settings)
val nestsServers =
com.vitorpamplona.amethyst.model.nip53NestsServers
.NestsServerListState(signer, cache, scope)
// Relay settings // Relay settings
val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope) val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope)
val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope) val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope)
@@ -3001,6 +3005,7 @@ class Account(
bookmarkState.getBookmarkList(), bookmarkState.getBookmarkList(),
pinState.getPinList(), pinState.getPinList(),
blossomServers.getBlossomServersList(), blossomServers.getBlossomServersList(),
nestsServers.getNestsServersList(),
paymentTargetsState.getPaymentTargetsEvent(), paymentTargetsState.getPaymentTargetsEvent(),
trustProviderList.getTrustProviderList(), trustProviderList.getTrustProviderList(),
cache.getAddressableNoteIfExists(appSpecific.getAppSpecificDataAddress())?.event, cache.getAddressableNoteIfExists(appSpecific.getAppSpecificDataAddress())?.event,
@@ -3069,6 +3074,8 @@ class Account(
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
suspend fun sendNestsServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(nestsServers.saveNestsServersList(servers))
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets)) suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
fun markAsRead( fun markAsRead(
@@ -2677,157 +2677,613 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean = ): Boolean =
try { try {
when (event) { when (event) {
is AcceptedBadgeSetEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AcceptedBadgeSetEvent -> {
is AdvertisedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is AppDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is AppRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AppSpecificDataEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AdvertisedRelayListEvent -> {
is AttestationEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is AttestationRequestEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is AttestorRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AttestorProficiencyEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AppDefinitionEvent -> {
is AudioHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is AudioTrackEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is BadgeAwardEvent -> consumeRegularEvent(event, relay, wasVerified)
is BadgeDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AppRecommendationEvent -> {
is BlockedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is BlossomServersEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is BroadcastRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is BookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AppSpecificDataEvent -> {
is OldBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is CalendarEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AttestationEvent -> {
is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is CallAnswerEvent -> consumeRegularEvent(event, relay, wasVerified) }
is CallHangupEvent -> consumeRegularEvent(event, relay, wasVerified)
is CallIceCandidateEvent -> consumeRegularEvent(event, relay, wasVerified) is AttestationRequestEvent -> {
is CallOfferEvent -> consumeRegularEvent(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is CallRejectEvent -> consumeRegularEvent(event, relay, wasVerified) }
is CallRenegotiateEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChannelCreateEvent -> consume(event, relay, wasVerified) is AttestorRecommendationEvent -> {
is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is ChannelHideMessageEvent -> consume(event, relay, wasVerified) }
is ChannelMessageEvent -> consume(event, relay, wasVerified)
is ChannelMetadataEvent -> consume(event, relay, wasVerified) is AttestorProficiencyEvent -> {
is ChannelMuteUserEvent -> consume(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is ChatMessageEncryptedFileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) }
is ChatMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatMessageRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AudioHeaderEvent -> {
is ClassifiedsEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is CommentEvent -> consumeRegularEvent(event, relay, wasVerified) }
is CommunityDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CommunityListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is AudioTrackEvent -> {
is CommunityPostApprovalEvent -> consume(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is ContactListEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is DeletionEvent -> consume(event, relay, wasVerified)
is DraftWrapEvent -> consume(event, relay, wasVerified) is BadgeAwardEvent -> {
is EmojiPackEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is EmojiPackSelectionEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is EphemeralChatEvent -> consume(event, relay, wasVerified)
is EphemeralChatListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is BadgeDefinitionEvent -> {
is ExternalIdentitiesEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is GenericRepostEvent -> consume(event, relay, wasVerified) }
is FhirResourceEvent -> consumeRegularEvent(event, relay, wasVerified)
is FileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) is BlockedRelayListEvent -> {
is ProfileGalleryEntryEvent -> consumeRegularEvent(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is FileServersEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is FileStorageEvent -> consume(event, relay, wasVerified)
is FileStorageHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) is BlossomServersEvent -> {
is FollowListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified) is com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent -> {
is GroupEvent -> consumeRegularEvent(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified) }
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified) is BroadcastRelayListEvent -> {
is GitRepositoryEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is RootSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is NamedSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified) is BookmarkListEvent -> {
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified) }
is KeyPackageEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is KeyPackageRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is OldBookmarkListEvent -> {
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarEvent -> {
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified) is CalendarDateSlotEvent -> {
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is InteractiveStorySceneEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is InteractiveStoryReadingStateEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarTimeSlotEvent -> {
is InterestSetEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) is CalendarRSVPEvent -> {
is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is LiveActivitiesClipEvent -> consume(event, relay, wasVerified) }
is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CallAnswerEvent -> {
is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is LnZapEvent -> consume(event, relay, wasVerified) }
is LnZapRequestEvent -> consume(event, relay, wasVerified)
is NIP90StatusEvent -> consumeRegularEvent(event, relay, wasVerified) is CallHangupEvent -> {
is NIP90ContentDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is NIP90ContentDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified) }
is NIP90UserDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
is NIP90UserDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified) is CallIceCandidateEvent -> {
is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified) }
is LongTextNoteEvent -> consume(event, relay, wasVerified)
is MetadataEvent -> consume(event, relay, wasVerified) is CallOfferEvent -> {
is MuteListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is NNSEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is NipTextEvent -> consume(event, relay, wasVerified)
is OtsEvent -> consume(event, relay, wasVerified) is CallRejectEvent -> {
is PictureEvent -> consumeRegularEvent(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is PrivateDmEvent -> consumeRegularEvent(event, relay, wasVerified) }
is PrivateOutboxRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ProfileBadgesEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CallRenegotiateEvent -> {
is ProxyRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is PinListEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is PublicMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
is PeopleListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ChannelCreateEvent -> {
is RequestToVanishEvent -> consumeRegularEvent(event, relay, wasVerified) consume(event, relay, wasVerified)
is CodeSnippetEvent -> consumeRegularEvent(event, relay, wasVerified) }
is ZapPollEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChatEvent -> consumeRegularEvent(event, relay, wasVerified) is ChannelListEvent -> {
is PollEvent -> consumeRegularEvent(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
is PollResponseEvent -> consume(event, relay, wasVerified) }
is RelayDiscoveryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is RelayMonitorEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ChannelHideMessageEvent -> {
is ReactionEvent -> consume(event, relay, wasVerified) consume(event, relay, wasVerified)
is ContactCardEvent -> consume(event, relay, wasVerified) }
is RelaySetEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ReportEvent -> consume(event, relay, wasVerified) is ChannelMessageEvent -> {
is RepostEvent -> consume(event, relay, wasVerified) consume(event, relay, wasVerified)
is SealedRumorEvent -> consumeRegularEvent(event, relay, wasVerified) }
is SearchRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is StatusEvent -> consume(event, relay, wasVerified) is ChannelMetadataEvent -> {
is TextNoteEvent -> consumeRegularEvent(event, relay, wasVerified) consume(event, relay, wasVerified)
is TextNoteModificationEvent -> consume(event, relay, wasVerified) }
is TorrentEvent -> consumeRegularEvent(event, relay, wasVerified)
is TorrentCommentEvent -> consumeRegularEvent(event, relay, wasVerified) is ChannelMuteUserEvent -> {
is TrustedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) consume(event, relay, wasVerified)
is TrustProviderListEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is VideoHorizontalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is VideoNormalEvent -> consumeRegularEvent(event, relay, wasVerified) is ChatMessageEncryptedFileHeaderEvent -> {
is VideoVerticalEvent -> consumeBaseReplaceable(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is VideoShortEvent -> consumeRegularEvent(event, relay, wasVerified) }
is VoiceEvent -> consumeRegularEvent(event, relay, wasVerified)
is VoiceReplyEvent -> consumeRegularEvent(event, relay, wasVerified) is ChatMessageEvent -> {
is WakeUpEvent -> consumeRegularEvent(event, relay, wasVerified) consumeRegularEvent(event, relay, wasVerified)
is WebBookmarkEvent -> consumeBaseReplaceable(event, relay, wasVerified) }
is WelcomeEvent -> consumeRegularEvent(event, relay, wasVerified)
is WikiNoteEvent -> consume(event, relay, wasVerified) is ChatMessageRelayListEvent -> {
is PaymentTargetsEvent -> consume(event, relay, wasVerified) consumeBaseReplaceable(event, relay, wasVerified)
else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } }
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) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -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<String>` of saved server base URLs
* - [getNestsServersListFlow] reactive `StateFlow<NoteState>` 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<NoteState> = nestsListNote.flow().metadata.stateFlow
fun getNestsServersList(): NestsServersEvent? = nestsListNote.event as? NestsServersEvent
fun normalizeServers(note: Note): List<String> {
val event = note.event as? NestsServersEvent
return event?.servers() ?: emptyList()
}
val flow: StateFlow<List<String>> =
getNestsServersListFlow()
.map {
normalizeServers(it.note)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun saveNestsServersList(servers: List<String>): 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,
)
}
}
}
@@ -58,6 +58,7 @@ val AccountInfoAndListsFromKeyKinds =
SearchRelayListEvent.KIND, SearchRelayListEvent.KIND,
FileServersEvent.KIND, FileServersEvent.KIND,
BlossomServersEvent.KIND, BlossomServersEvent.KIND,
com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND,
PrivateOutboxRelayListEvent.KIND, PrivateOutboxRelayListEvent.KIND,
) )
@@ -52,6 +52,7 @@ val BasicAccountInfoKinds =
SearchRelayListEvent.KIND, SearchRelayListEvent.KIND,
FileServersEvent.KIND, FileServersEvent.KIND,
BlossomServersEvent.KIND, BlossomServersEvent.KIND,
com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND,
) )
val BasicAccountInfoKinds2 = val BasicAccountInfoKinds2 =
@@ -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<NestsServer> =
listOf(
NestsServer(name = "nostrnests.com", baseUrl = "https://moq.nostrnests.com"),
)
@@ -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<String>` 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<List<NestsServer>>(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<String>) {
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,
)
@@ -319,6 +319,10 @@ fun BuildNavigation(
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) } composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditNestsServers> {
com.vitorpamplona.amethyst.ui.actions.nestsServers
.NestsServersScreen(accountViewModel, nav)
}
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) } composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) } composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
@@ -251,6 +251,8 @@ sealed class Route {
@Serializable object EditMediaServers : Route() @Serializable object EditMediaServers : Route()
@Serializable object EditNestsServers : Route()
@Serializable object EditFavoriteAlgoFeeds : Route() @Serializable object EditFavoriteAlgoFeeds : Route()
@Serializable object EditPaymentTargets : Route() @Serializable object EditPaymentTargets : Route()
@@ -20,10 +20,18 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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 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.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.screen.loggedIn.audiorooms.datasource.AudioRoomsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
@Composable @Composable
fun AudioRoomsScreen( fun AudioRoomsScreen(
@@ -59,6 +69,8 @@ fun AudioRoomsScreen(
WatchAccountForAudioRoomsScreen(audioRoomsFeedState = audioRoomsFeedContentState, accountViewModel = accountViewModel) WatchAccountForAudioRoomsScreen(audioRoomsFeedState = audioRoomsFeedContentState, accountViewModel = accountViewModel)
AudioRoomsFilterAssemblerSubscription(accountViewModel) AudioRoomsFilterAssemblerSubscription(accountViewModel)
var showCreateSheet by remember { mutableStateOf(false) }
DisappearingScaffold( DisappearingScaffold(
isInvertedLayout = false, isInvertedLayout = false,
topBar = { 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, accountViewModel = accountViewModel,
) { ) {
RefresheableBox(audioRoomsFeedContentState, true) { RefresheableBox(audioRoomsFeedContentState, true) {
@@ -95,6 +118,13 @@ fun AudioRoomsScreen(
} }
} }
} }
if (showCreateSheet) {
CreateAudioRoomSheet(
accountViewModel = accountViewModel,
onDismiss = { showCreateSheet = false },
)
}
} }
@Composable @Composable
@@ -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))
}
}
}
}
}
}
@@ -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<FormState> = _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"
}
}
@@ -543,6 +543,7 @@ fun kindDisplayName(kind: Int): Int =
BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions
BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays
BlossomServersEvent.KIND -> R.string.kind_blossom_servers 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 BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth
BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays
BookmarkListEvent.KIND -> R.string.kind_bookmark_list BookmarkListEvent.KIND -> R.string.kind_bookmark_list
@@ -127,6 +127,13 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.EditMediaServers) }, onClick = { nav.nav(Route.EditMediaServers) },
) )
HorizontalDivider() HorizontalDivider()
SettingsNavigationRow(
title = R.string.nests_servers_title,
icon = MaterialSymbols.CloudUpload,
tint = tint,
onClick = { nav.nav(Route.EditNestsServers) },
)
HorizontalDivider()
SettingsNavigationRow( SettingsNavigationRow(
title = R.string.profile_badges_title, title = R.string.profile_badges_title,
icon = MaterialSymbols.MilitaryTech, icon = MaterialSymbols.MilitaryTech,
+23
View File
@@ -521,6 +521,28 @@
<string name="audio_room_notification_stop">Stop</string> <string name="audio_room_notification_stop">Stop</string>
<string name="audio_room_join">Join audio room</string> <string name="audio_room_join">Join audio room</string>
<string name="audio_room_leave">Leave</string> <string name="audio_room_leave">Leave</string>
<string name="audio_room_create_fab">Start space</string>
<string name="audio_room_create_title">Start a new audio room</string>
<string name="audio_room_create_field_room">Room name</string>
<string name="audio_room_create_field_summary">What\'s it about?</string>
<string name="audio_room_create_field_service">MoQ service URL</string>
<string name="audio_room_create_field_service_hint">Auth sidecar — defaults to nostrnests.com</string>
<string name="audio_room_create_field_endpoint">MoQ relay endpoint</string>
<string name="audio_room_create_field_endpoint_hint">WebTransport URL — usually the same host</string>
<string name="audio_room_create_field_image">Cover image URL (optional)</string>
<string name="audio_room_create_cancel">Cancel</string>
<string name="audio_room_create_submit">Start space</string>
<string name="nests_servers_title">Audio-room servers</string>
<string name="nests_servers_explainer">Choose which MoQ host servers Amethyst publishes your audio rooms to. The first entry is used by default when you start a new space.</string>
<string name="nests_servers_my_section">Your servers</string>
<string name="nests_servers_my_explainer">Saved as a kind-10062 replaceable event so other clients can read your preference.</string>
<string name="nests_servers_add_field">Add an audio-room server</string>
<string name="nests_servers_recommended_section">Recommended servers</string>
<string name="nests_servers_recommended_explainer">Built-in suggestions you can add to your list with one tap.</string>
<string name="nests_servers_use_defaults">Use Amethyst defaults</string>
<string name="nests_servers_add_recommended">Add server</string>
<string name="nests_servers_remove">Remove server</string>
<string name="nests_servers_empty">No audio-room servers yet. Add one below or pick a recommended server.</string>
<string name="longs">Videos</string> <string name="longs">Videos</string>
<string name="articles">Articles</string> <string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string> <string name="private_bookmarks">Private Bookmarks</string>
@@ -2071,6 +2093,7 @@
<string name="kind_profile_badges">Profile Badges</string> <string name="kind_profile_badges">Profile Badges</string>
<string name="kind_blocked_relays">Blocked Relays</string> <string name="kind_blocked_relays">Blocked Relays</string>
<string name="kind_blossom_servers">Blossom Servers</string> <string name="kind_blossom_servers">Blossom Servers</string>
<string name="kind_nests_servers">Audio-room (Nests) Servers</string>
<string name="kind_blossom_auth">Blossom Auth</string> <string name="kind_blossom_auth">Blossom Auth</string>
<string name="kind_broadcast_relays">Broadcast Relays</string> <string name="kind_broadcast_relays">Broadcast Relays</string>
<string name="kind_bookmark_list">Bookmark List</string> <string name="kind_bookmark_list">Bookmark List</string>
@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun servers(): List<String> =
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<String>): Array<Array<String>> =
servers
.map { arrayOf("server", it) }
.plusElement(AltTag.assemble(ALT))
.toTypedArray()
suspend fun updateRelayList(
earlierVersion: NestsServersEvent,
servers: List<String>,
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<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = create(relays, signer, createdAt)
suspend fun create(
servers: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = signer.sign<NestsServersEvent>(createdAt, KIND, createTagArray(servers), "")
}
}
@@ -168,6 +168,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent 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.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -332,6 +333,7 @@ class EventFactory {
BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig) BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig)
BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig) BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig)
BlossomServersEvent.KIND -> BlossomServersEvent(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) BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
BroadcastRelayListEvent.KIND -> BroadcastRelayListEvent(id, pubKey, createdAt, tags, content, sig) BroadcastRelayListEvent.KIND -> BroadcastRelayListEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)