feat(nests): use shared EditFieldRow for nest chat composer

Wire the in-room chat composer to ChannelNewMessageViewModel +
EditFieldRow so nest chat picks up @-mention picker, file/image/
video upload, reply preview, NIP-37 draft auto-save, emoji
suggestions, content warnings, expiration, geohash, and zap-
split forwarding — the same affordances every other public chat
surface has.

The composer is backed by a LiveActivitiesChannel keyed by the
meeting space's address. ChannelNewMessageViewModel.createTemplate()
already handles the LiveActivitiesChannel branch: when channel.info
is null (which it always is for kind-30312, since info is typed to
the kind-30311 LiveActivitiesEvent), it falls through to
LiveActivitiesChatMessageEvent.message(post, channel.toATag()) —
the exact event shape the previous slim composer was emitting.

EditFieldRow gains an interceptBackPress flag so nest can opt out
of the channel-screen back-handler that would clear the field and
call nav.popBack() (a no-op on BouncingIntentNav).
This commit is contained in:
Claude
2026-04-27 22:09:45 +00:00
parent 40fa069a98
commit 46cfab4fa1
2 changed files with 60 additions and 114 deletions
@@ -63,13 +63,16 @@ fun EditFieldRow(
accountViewModel: AccountViewModel,
onSendNewMessage: suspend () -> Unit,
nav: INav,
interceptBackPress: Boolean = true,
) {
BackHandler {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
if (interceptBackPress) {
BackHandler {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
}
nav.popBack()
}
nav.popBack()
}
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
@@ -31,59 +31,64 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel as composeViewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
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.utils.ThinSendButton
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.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.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import kotlinx.coroutines.launch
/**
* In-room chat panel. Renders the kind-1311 transcript that the
* [NestViewModel] is collecting (`viewModel.chat`) using the same
* per-message renderer that NIP-53 live-stream chat uses
* ([ChatroomMessageCompose]) and the same composer primitives
* ([ThinPaddingTextField] + [ThinSendButton]) so visually it matches
* the rest of Amethyst's chat surfaces.
* ([ChatroomMessageCompose]).
*
* Data flow stays inside [NestViewModel]: messages come from
* `viewModel.chat`; sends route through `accountViewModel.account
* .signAndComputeBroadcast` which is the same path the listener
* subscribes to via `RoomChatFilterAssemblerSubscription` in
* [NestActivityContent]. We deliberately do NOT spin up a parallel
* `ChannelFeedViewModel` / `ChannelNewMessageViewModel` here — keeping
* the VM single-source-of-truth means screen extras (presence,
* reactions, hand-raise) and chat are aggregated under the same
* lifecycle.
* 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.
*
* 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
@@ -92,10 +97,11 @@ import kotlinx.coroutines.launch
* 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.
*
* Composer is intentionally slim for v1: text-only, no draft handling,
* no media attachments, no @-mention picker, no reply preview. Those
* affordances live in `EditFieldRow` / `ChannelNewMessageViewModel`
* which are pinned to the channel-VM model — out of scope here.
* 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.
*/
@Composable
internal fun ColumnScope.NestChatPanel(
@@ -109,17 +115,17 @@ internal fun ColumnScope.NestChatPanel(
val scope = rememberCoroutineScope()
val nav = remember(context, scope) { BouncingIntentNav(context, scope) }
val roomATag =
val channel =
remember(event) {
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
)
accountViewModel.checkGetOrCreateLiveActivityChannel(event.address())
}
val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" }
val channelScreenModel: ChannelNewMessageViewModel =
composeViewModel(key = "Nest/${event.address().toValue()}")
channelScreenModel.init(accountViewModel)
channelScreenModel.load(channel)
Column(modifier = modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
if (messages.isEmpty()) {
@@ -135,15 +141,19 @@ internal fun ColumnScope.NestChatPanel(
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = channelScreenModel::reply,
)
}
}
Spacer(Modifier.height(8.dp))
NestChatComposer(
roomATag = roomATag,
EditFieldRow(
channelScreenModel = channelScreenModel,
accountViewModel = accountViewModel,
onSendNewMessage = {},
nav = nav,
interceptBackPress = false,
)
}
}
@@ -154,6 +164,7 @@ private fun NestChatMessageList(
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: BouncingIntentNav,
onWantsToReply: (Note) -> Unit,
) {
val listState = rememberLazyListState()
@@ -192,79 +203,11 @@ private fun NestChatMessageList(
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = NEST_CHAT_NO_OP_NOTE,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = NEST_CHAT_NO_OP_NOTE,
)
}
}
}
@Composable
private fun NestChatComposer(
roomATag: ATag,
accountViewModel: AccountViewModel,
) {
val state = remember { TextFieldState() }
var isSending by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(modifier = EditFieldModifier) {
ThinPaddingTextField(
state = state,
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
enabled = !isSending,
placeholder = {
Text(
text = stringRes(R.string.nest_chat_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
ThinSendButton(
isActive = state.text.isNotBlank() && !isSending,
modifier = EditFieldTrailingIconModifier,
) {
val toSend = state.text.toString().trim()
if (toSend.isEmpty()) return@ThinSendButton
isSending = true
scope.launch {
val result =
runCatching {
accountViewModel.account.signAndComputeBroadcast(
LiveActivitiesChatMessageEvent.message(
post = toSend,
activity = roomATag,
),
)
}
isSending = false
if (result.isSuccess) {
// Clear ONLY on success so a network failure
// doesn't lose the user's text — they can retry
// without retyping.
state.clearText()
} else {
val why =
result.exceptionOrNull()?.message
?: result.exceptionOrNull()?.let { it::class.simpleName }
?: "unknown error"
accountViewModel.toastManager.toast(
R.string.nest_chat_send_failed_title,
why,
user = null,
)
}
}
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
}
private val NEST_CHAT_NO_OP_NOTE: (com.vitorpamplona.amethyst.model.Note) -> Unit = {}
private val NEST_CHAT_NO_OP_NOTE: (Note) -> Unit = {}