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:
@@ -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<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))
|
||||
|
||||
fun markAsRead(
|
||||
|
||||
@@ -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
|
||||
|
||||
+100
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -58,6 +58,7 @@ val AccountInfoAndListsFromKeyKinds =
|
||||
SearchRelayListEvent.KIND,
|
||||
FileServersEvent.KIND,
|
||||
BlossomServersEvent.KIND,
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND,
|
||||
PrivateOutboxRelayListEvent.KIND,
|
||||
)
|
||||
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ val BasicAccountInfoKinds =
|
||||
SearchRelayListEvent.KIND,
|
||||
FileServersEvent.KIND,
|
||||
BlossomServersEvent.KIND,
|
||||
com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND,
|
||||
)
|
||||
|
||||
val BasicAccountInfoKinds2 =
|
||||
|
||||
+252
@@ -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"),
|
||||
)
|
||||
+130
@@ -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.VanishEvents> { VanishEventsScreen(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.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -251,6 +251,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object EditNestsServers : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
|
||||
+30
@@ -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
|
||||
|
||||
+197
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
@@ -521,6 +521,28 @@
|
||||
<string name="audio_room_notification_stop">Stop</string>
|
||||
<string name="audio_room_join">Join audio room</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="articles">Articles</string>
|
||||
<string name="private_bookmarks">Private Bookmarks</string>
|
||||
@@ -2071,6 +2093,7 @@
|
||||
<string name="kind_profile_badges">Profile Badges</string>
|
||||
<string name="kind_blocked_relays">Blocked Relays</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_broadcast_relays">Broadcast Relays</string>
|
||||
<string name="kind_bookmark_list">Bookmark List</string>
|
||||
|
||||
+123
@@ -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.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)
|
||||
|
||||
Reference in New Issue
Block a user