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:
Claude
2026-04-27 22:37:18 +00:00
parent 57ebded68b
commit 995f8384df
4 changed files with 302 additions and 52 deletions
@@ -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,
)
}
}
@@ -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),
)
}
}
@@ -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,
)
}
@@ -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)
}