From 46cfab4fa1f42c382491a8b94f4f7d00cebac887 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 22:09:45 +0000 Subject: [PATCH] feat(nests): use shared EditFieldRow for nest chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../chats/publicChannels/send/EditFieldRow.kt | 13 +- .../loggedIn/nests/room/NestChatPanel.kt | 161 ++++++------------ 2 files changed, 60 insertions(+), 114 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index c9e63c90f..5758215b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt index c0eba15ed..940a15f67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt @@ -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 = {}