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 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
|
||||||
|
|||||||
+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,
|
SearchRelayListEvent.KIND,
|
||||||
FileServersEvent.KIND,
|
FileServersEvent.KIND,
|
||||||
BlossomServersEvent.KIND,
|
BlossomServersEvent.KIND,
|
||||||
|
com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent.KIND,
|
||||||
PrivateOutboxRelayListEvent.KIND,
|
PrivateOutboxRelayListEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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 =
|
||||||
|
|||||||
+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.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()
|
||||||
|
|||||||
+30
@@ -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
|
||||||
|
|||||||
+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
|
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
|
||||||
|
|||||||
+7
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+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.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)
|
||||||
|
|||||||
Reference in New Issue
Block a user