feat(nests): wire nest chat composer to NestNewMessageViewModel
Completes the copy approach started in 57ebded6:
- NestEditFieldRow.kt: copy of EditFieldRow, drops the BackHandler
(the activity owns back; draft auto-save runs on every keystroke
so there is nothing to flush) and points every callback at
NestNewMessageViewModel.
- NestFileUploadDialog.kt: copy of ChannelFileUploadDialog, header
shows the room host's avatar + room name from the loaded
MeetingSpaceEvent.
- NestChatPanel.kt: replaces the slim inline composer with
NestEditFieldRow + NestNewMessageViewModel. Chat message list is
still rendered from NestViewModel.chat — the new VM is scoped to
*editing and sending* messages only, per the brief.
- NestNewMessageViewModel.sendPostSync: switched from
signAndSendPrivately(template, emptySet()) to
signAndComputeBroadcast(template) so messages actually reach the
user's default relays (matches the original slim composer's
behaviour).
This commit is contained in:
+22
-51
@@ -51,8 +51,8 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.BouncingIntentNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.send.NestEditFieldRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.send.NestNewMessageViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
@@ -63,45 +63,21 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv
|
||||
* per-message renderer that NIP-53 live-stream chat uses
|
||||
* ([ChatroomMessageCompose]).
|
||||
*
|
||||
* Composer is the same [EditFieldRow] used by every other public-chat
|
||||
* surface in the app, so we get @-mention picker, file/image/video
|
||||
* upload, reply preview, draft auto-save, emoji suggestions, content
|
||||
* warnings, expiration, geohash, and zap-split forwarding for free.
|
||||
* The composer is owned by a separate [NestNewMessageViewModel] —
|
||||
* scoped to *editing and sending* chat messages only — so we get
|
||||
* @-mention picker, file/image/video upload, reply preview, NIP-37
|
||||
* draft auto-save, emoji suggestions, content warnings, expiration,
|
||||
* geohash, and zap-split forwarding for the nest chat field. The
|
||||
* message list itself is still driven from the [NestViewModel] so
|
||||
* presence / reactions / chat all share a single lifecycle.
|
||||
*
|
||||
* To stay aligned with the rest of the app, the composer is backed by
|
||||
* a [ChannelNewMessageViewModel] loaded with a [LiveActivitiesChannel]
|
||||
* keyed by the meeting space's address. `ChannelNewMessageViewModel.
|
||||
* createTemplate()` already handles the `LiveActivitiesChannel` →
|
||||
* `LiveActivitiesChatMessageEvent` path: when `channel.info` is null
|
||||
* (we never populate it for kind-30312 — it's typed to the kind-30311
|
||||
* `LiveActivitiesEvent`), it falls through to the
|
||||
* `LiveActivitiesChatMessageEvent.message(message, channel.toATag())`
|
||||
* branch, which produces exactly the same event the original slim
|
||||
* composer was emitting.
|
||||
*
|
||||
* Data flow: messages still arrive via the existing nest pipeline
|
||||
* (`RoomChatFilterAssemblerSubscription` → `NestViewModel.onChatEvent`
|
||||
* → `viewModel.chat`). The composer emits via
|
||||
* `account.signAndSendPrivately(template, channelRelays)` inside
|
||||
* `ChannelNewMessageViewModel.sendPostSync`, which lands on the same
|
||||
* relays the listener subscribes to, so the user sees their own
|
||||
* message immediately when it round-trips. Drafts are stored as
|
||||
* NIP-37 wrap events through the standard account draft pipeline —
|
||||
* they do not pollute `viewModel.chat` because that flow only accepts
|
||||
* kind-1311 events.
|
||||
*
|
||||
* Navigation: the activity has no Compose NavHost in scope. Taps that
|
||||
* resolve to a NIP-19 entity (profile, quoted note, hashtag, addressable
|
||||
* channel) are dispatched through [BouncingIntentNav] — a `nostr:` URI
|
||||
* Intent at MainActivity. Anything that doesn't have a `nostr:` URI is
|
||||
* a no-op for now. The audio room keeps running in its own task while
|
||||
* the user explores; back from MainActivity returns to the room.
|
||||
*
|
||||
* The composer's default back-press handler from [EditFieldRow] would
|
||||
* try to `nav.popBack()` (a no-op on [BouncingIntentNav]) and clear
|
||||
* the field — both wrong inside an Activity-scoped chat panel where
|
||||
* back means "leave the room". We pass `interceptBackPress = false`
|
||||
* so system back continues to flow to the activity.
|
||||
* Navigation: the activity has no Compose NavHost in scope. Taps
|
||||
* that resolve to a NIP-19 entity (profile, quoted note, hashtag,
|
||||
* addressable channel) are dispatched through [BouncingIntentNav]
|
||||
* — a `nostr:` URI Intent at MainActivity. Anything that doesn't
|
||||
* have a `nostr:` URI is a no-op for now. The audio room keeps
|
||||
* running in its own task while the user explores; back from
|
||||
* MainActivity returns to the room.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.NestChatPanel(
|
||||
@@ -115,16 +91,12 @@ internal fun ColumnScope.NestChatPanel(
|
||||
val scope = rememberCoroutineScope()
|
||||
val nav = remember(context, scope) { BouncingIntentNav(context, scope) }
|
||||
|
||||
val channel =
|
||||
remember(event) {
|
||||
accountViewModel.checkGetOrCreateLiveActivityChannel(event.address())
|
||||
}
|
||||
val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" }
|
||||
|
||||
val channelScreenModel: ChannelNewMessageViewModel =
|
||||
val nestScreenModel: NestNewMessageViewModel =
|
||||
composeViewModel(key = "Nest/${event.address().toValue()}")
|
||||
channelScreenModel.init(accountViewModel)
|
||||
channelScreenModel.load(channel)
|
||||
nestScreenModel.init(accountViewModel)
|
||||
nestScreenModel.load(event)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
|
||||
@@ -141,19 +113,18 @@ internal fun ColumnScope.NestChatPanel(
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToReply = channelScreenModel::reply,
|
||||
onWantsToReply = nestScreenModel::reply,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
EditFieldRow(
|
||||
channelScreenModel = channelScreenModel,
|
||||
NestEditFieldRow(
|
||||
nestScreenModel = nestScreenModel,
|
||||
accountViewModel = accountViewModel,
|
||||
onSendNewMessage = {},
|
||||
nav = nav,
|
||||
interceptBackPress = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.nests.room.send
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
|
||||
/**
|
||||
* Mirror of `EditFieldRow` for the nest-room composer. Uses
|
||||
* [NestNewMessageViewModel] which is scoped to a single
|
||||
* [com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent]
|
||||
* and only emits kind-1311 chat messages.
|
||||
*
|
||||
* Renders, top-to-bottom: stripping-failure dialog, reply preview,
|
||||
* file-upload dialog, @-mention picker, emoji suggestions, and the
|
||||
* text field with gallery picker + send button.
|
||||
*
|
||||
* Differences from the channel `EditFieldRow`:
|
||||
* - No `BackHandler`: the nest chat panel lives inside an Activity
|
||||
* that owns its own back behaviour (leave room / enter PiP). The
|
||||
* composer's draft auto-save runs on every text change via
|
||||
* [NestNewMessageViewModel.draftTag], so there is nothing to flush
|
||||
* on back press.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
fun NestEditFieldRow(
|
||||
nestScreenModel: NestNewMessageViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSendNewMessage: suspend () -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
StrippingFailureDialog(nestScreenModel.strippingFailureConfirmation)
|
||||
|
||||
nestScreenModel.replyTo.value?.let {
|
||||
DisplayReplyingToNote(it, accountViewModel, nav) {
|
||||
nestScreenModel.clearReply()
|
||||
}
|
||||
}
|
||||
|
||||
nestScreenModel.uploadState?.let { uploading ->
|
||||
uploading.multiOrchestrator?.let {
|
||||
NestFileUploadDialog(
|
||||
nestScreenModel = nestScreenModel,
|
||||
state = uploading,
|
||||
onUpload = onSendNewMessage,
|
||||
onCancel = uploading::reset,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = EditFieldModifier,
|
||||
) {
|
||||
nestScreenModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
it,
|
||||
nestScreenModel::autocompleteWithUser,
|
||||
accountViewModel,
|
||||
SuggestionListDefaultHeightChat,
|
||||
)
|
||||
}
|
||||
|
||||
nestScreenModel.emojiSuggestions?.let {
|
||||
ShowEmojiSuggestionList(
|
||||
it,
|
||||
nestScreenModel::autocompleteWithEmoji,
|
||||
nestScreenModel::autocompleteWithEmojiUrl,
|
||||
SuggestionListDefaultHeightChat,
|
||||
)
|
||||
}
|
||||
|
||||
ThinPaddingTextField(
|
||||
state = nestScreenModel.message,
|
||||
onTextChanged = { nestScreenModel.onMessageChanged() },
|
||||
inputTransformation = MentionPreservingInputTransformation,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
shape = EditFieldBorder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.reply_here),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
trailingIcon = {
|
||||
ThinSendButton(
|
||||
isActive =
|
||||
nestScreenModel.message.text.isNotBlank() && !nestScreenModel.isUploadingImage,
|
||||
modifier = EditFieldTrailingIconModifier,
|
||||
) {
|
||||
nestScreenModel.sendPost(onSendNewMessage)
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
SelectFromGallery(
|
||||
isUploading = nestScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.height(32.dp).padding(start = 2.dp),
|
||||
onImageChosen = nestScreenModel::pickedMedia,
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.nests.room.send
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
|
||||
/**
|
||||
* Mirror of `ChannelFileUploadDialog` for nest chat. Wraps the
|
||||
* generic [ChatFileUploadDialog] with a header showing the host
|
||||
* avatar + the room name pulled from the [NestNewMessageViewModel]'s
|
||||
* loaded [com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent].
|
||||
*/
|
||||
@Composable
|
||||
fun NestFileUploadDialog(
|
||||
nestScreenModel: NestNewMessageViewModel,
|
||||
state: ChatFileUploadState,
|
||||
onUpload: suspend () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val room = nestScreenModel.room ?: return
|
||||
val context = LocalContext.current
|
||||
|
||||
val host = remember(room.pubKey) { LocalCache.getOrCreateUser(room.pubKey) }
|
||||
val title = room.room().orEmpty()
|
||||
|
||||
ChatFileUploadDialog(
|
||||
state,
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UserPicture(
|
||||
user = host,
|
||||
size = Size34dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.height(35.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
upload = {
|
||||
nestScreenModel.upload(
|
||||
onError = accountViewModel.toastManager::toast,
|
||||
context = context,
|
||||
onceUploaded = onUpload,
|
||||
)
|
||||
|
||||
accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer)
|
||||
accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata)
|
||||
},
|
||||
onCancel,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
+5
-1
@@ -297,7 +297,11 @@ open class NestNewMessageViewModel :
|
||||
val version = draftTag.current
|
||||
cancel()
|
||||
|
||||
accountViewModel.account.signAndSendPrivately(template, emptySet())
|
||||
// Broadcast to the user's default relays — the nest has no
|
||||
// dedicated relay set the way a public-chat channel does, so
|
||||
// this matches what the original slim composer was doing via
|
||||
// `account.signAndComputeBroadcast(...)`.
|
||||
accountViewModel.account.signAndComputeBroadcast(template)
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user