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, accountViewModel: AccountViewModel,
onSendNewMessage: suspend () -> Unit, onSendNewMessage: suspend () -> Unit,
nav: INav, nav: INav,
interceptBackPress: Boolean = true,
) { ) {
BackHandler { if (interceptBackPress) {
accountViewModel.launchSigner { BackHandler {
channelScreenModel.sendDraftSync() accountViewModel.launchSigner {
channelScreenModel.cancel() channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
}
nav.popBack()
} }
nav.popBack()
} }
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation) StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
@@ -31,59 +31,64 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel as composeViewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.model.LocalCache 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.navigation.navs.BouncingIntentNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.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.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.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import kotlinx.coroutines.launch
/** /**
* In-room chat panel. Renders the kind-1311 transcript that the * In-room chat panel. Renders the kind-1311 transcript that the
* [NestViewModel] is collecting (`viewModel.chat`) using the same * [NestViewModel] is collecting (`viewModel.chat`) using the same
* per-message renderer that NIP-53 live-stream chat uses * per-message renderer that NIP-53 live-stream chat uses
* ([ChatroomMessageCompose]) and the same composer primitives * ([ChatroomMessageCompose]).
* ([ThinPaddingTextField] + [ThinSendButton]) so visually it matches
* the rest of Amethyst's chat surfaces.
* *
* Data flow stays inside [NestViewModel]: messages come from * Composer is the same [EditFieldRow] used by every other public-chat
* `viewModel.chat`; sends route through `accountViewModel.account * surface in the app, so we get @-mention picker, file/image/video
* .signAndComputeBroadcast` which is the same path the listener * upload, reply preview, draft auto-save, emoji suggestions, content
* subscribes to via `RoomChatFilterAssemblerSubscription` in * warnings, expiration, geohash, and zap-split forwarding for free.
* [NestActivityContent]. We deliberately do NOT spin up a parallel *
* `ChannelFeedViewModel` / `ChannelNewMessageViewModel` here — keeping * To stay aligned with the rest of the app, the composer is backed by
* the VM single-source-of-truth means screen extras (presence, * a [ChannelNewMessageViewModel] loaded with a [LiveActivitiesChannel]
* reactions, hand-raise) and chat are aggregated under the same * keyed by the meeting space's address. `ChannelNewMessageViewModel.
* lifecycle. * 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 * Navigation: the activity has no Compose NavHost in scope. Taps that
* resolve to a NIP-19 entity (profile, quoted note, hashtag, addressable * 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 * 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 user explores; back from MainActivity returns to the room.
* *
* Composer is intentionally slim for v1: text-only, no draft handling, * The composer's default back-press handler from [EditFieldRow] would
* no media attachments, no @-mention picker, no reply preview. Those * try to `nav.popBack()` (a no-op on [BouncingIntentNav]) and clear
* affordances live in `EditFieldRow` / `ChannelNewMessageViewModel` * the field — both wrong inside an Activity-scoped chat panel where
* which are pinned to the channel-VM model — out of scope here. * back means "leave the room". We pass `interceptBackPress = false`
* so system back continues to flow to the activity.
*/ */
@Composable @Composable
internal fun ColumnScope.NestChatPanel( internal fun ColumnScope.NestChatPanel(
@@ -109,17 +115,17 @@ internal fun ColumnScope.NestChatPanel(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val nav = remember(context, scope) { BouncingIntentNav(context, scope) } val nav = remember(context, scope) { BouncingIntentNav(context, scope) }
val roomATag = val channel =
remember(event) { remember(event) {
ATag( accountViewModel.checkGetOrCreateLiveActivityChannel(event.address())
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
)
} }
val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" } 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()) { Column(modifier = modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) { Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
if (messages.isEmpty()) { if (messages.isEmpty()) {
@@ -135,15 +141,19 @@ internal fun ColumnScope.NestChatPanel(
routeForLastRead = routeForLastRead, routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
onWantsToReply = channelScreenModel::reply,
) )
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
NestChatComposer( EditFieldRow(
roomATag = roomATag, channelScreenModel = channelScreenModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onSendNewMessage = {},
nav = nav,
interceptBackPress = false,
) )
} }
} }
@@ -154,6 +164,7 @@ private fun NestChatMessageList(
routeForLastRead: String, routeForLastRead: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: BouncingIntentNav, nav: BouncingIntentNav,
onWantsToReply: (Note) -> Unit,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -192,79 +203,11 @@ private fun NestChatMessageList(
routeForLastRead = routeForLastRead, routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
onWantsToReply = NEST_CHAT_NO_OP_NOTE, onWantsToReply = onWantsToReply,
onWantsToEditDraft = NEST_CHAT_NO_OP_NOTE, onWantsToEditDraft = NEST_CHAT_NO_OP_NOTE,
) )
} }
} }
} }
@Composable private val NEST_CHAT_NO_OP_NOTE: (Note) -> Unit = {}
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 = {}