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