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:
+8
-5
@@ -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)
|
||||
|
||||
+52
-109
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user