feat(nests): rebuild chat UI on NestViewModel.chat with LiveStream renderer
Replaces the v2 ChannelFeedViewModel/ChannelNewMessageViewModel detour
(commit bdb82f0) with a chat panel that:
- Reads messages from `NestViewModel.chat` directly (single source of
truth for the room — same VM that holds presence, reactions, and
hand-raise queue),
- Renders each message via `ChatroomMessageCompose` — the exact same
per-row composable that LiveStream chat uses in
`ChatFeedLoaded` — so visual rendering (avatars, names,
timestamps, NIP-21 mentions, embedded media, link previews,
reactions count) matches the rest of Amethyst's chat surfaces,
- Uses a slim composer reusing `ThinPaddingTextField` +
`ThinSendButton` (the same components inside `EditFieldRow`),
- LazyColumn with `reverseLayout=true` and the message list reversed,
so newest is at the bottom and auto-scroll works naturally.
Sends route through `accountViewModel.account.signAndComputeBroadcast`
of `LiveActivitiesChatMessageEvent.message(text, roomATag)` — same wire
path the room subscription is already listening on.
Composer is intentionally slim for v1: text-only. Drafts, @-mention
picker, file attachments, and reply preview are pinned to
ChannelNewMessageViewModel and out of scope here. Adding any of them is
a follow-up that can either (a) port the relevant primitives onto
NestViewModel or (b) widen NestViewModel to expose those fields.
New: `BouncingIntentNav` — Activity-context INav that translates the
small set of nav requests a chat row generates (Profile / Note /
Hashtag / EventRedirect / LiveActivityChannel / Community /
PublicChatChannel) into `nostr:` URIs and bounces them as ACTION_VIEW
Intents at MainActivity. AppNavigation already routes intent.data
through `uriToRoute`, so destinations land in the main app's NavHost
without receiver-side changes. NestActivity stays running in its own
task while the user explores; back from MainActivity returns to the
room with audio uninterrupted (foreground service).
Routes that don't have a `nostr:` mapping (settings, drafts, private
chatrooms, etc.) are no-ops in the bouncing nav — those aren't
reachable from a chat-row tap anyway.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.navs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* INav for screens that live inside a free-standing Activity (no Compose
|
||||
* NavHost in scope) and need to dispatch navigation requests back to
|
||||
* [MainActivity]'s nav graph — typically a chat surface inside the
|
||||
* audio-room ([com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.NestActivity]).
|
||||
*
|
||||
* Each [nav] / [newStack] / [navBottomBar] call is translated into a
|
||||
* `nostr:` URI (when the route maps to a NIP-19 entity) and dispatched
|
||||
* to [MainActivity] via `Intent.ACTION_VIEW`. AppNavigation already
|
||||
* picks up `intent.data` and runs `uriToRoute` against it on resume,
|
||||
* so the destination route lands in MainActivity's NavHost without
|
||||
* any receiver-side changes.
|
||||
*
|
||||
* Routes that can't be expressed as a `nostr:` URI (settings, drafts,
|
||||
* private chatrooms, …) are silently skipped — those aren't reachable
|
||||
* from a chat-message tap anyway. Adding more cases later is one line
|
||||
* each in [routeToBouncingUri].
|
||||
*
|
||||
* Launch flags: [Intent.FLAG_ACTIVITY_NEW_TASK] +
|
||||
* [Intent.FLAG_ACTIVITY_CLEAR_TOP]. The user lands on a clean
|
||||
* MainActivity (or brings the existing one to front), with the
|
||||
* NestActivity left running in its own task — the foreground audio
|
||||
* service keeps audio playing, and a back-press from MainActivity
|
||||
* returns to the room.
|
||||
*
|
||||
* `popBack` and `popUpTo` are no-ops; the bouncing model means
|
||||
* "leaving" MainActivity is the user's job (system back). `closeDrawer`
|
||||
* / `openDrawer` operate on a local DrawerState that nothing renders;
|
||||
* implementing them is part of the [INav] contract.
|
||||
*/
|
||||
@Stable
|
||||
class BouncingIntentNav(
|
||||
private val context: Context,
|
||||
override val navigationScope: CoroutineScope,
|
||||
) : INav {
|
||||
override val drawerState = DrawerState(DrawerValue.Closed)
|
||||
|
||||
override fun closeDrawer() = runBlocking { drawerState.close() }
|
||||
|
||||
override fun openDrawer() = runBlocking { drawerState.open() }
|
||||
|
||||
override fun nav(route: Route) {
|
||||
val uri = routeToBouncingUri(route)
|
||||
if (uri == null) {
|
||||
Log.d("BouncingIntentNav") { "no nostr: URI for $route — skipping" }
|
||||
return
|
||||
}
|
||||
bounce(uri)
|
||||
}
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
navigationScope.launch {
|
||||
computeRoute()?.let { nav(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun newStack(route: Route) = nav(route)
|
||||
|
||||
override fun navBottomBar(route: Route) = nav(route)
|
||||
|
||||
override fun popBack() {
|
||||
// No-op: this nav is for in-Activity chat navigation that
|
||||
// exclusively forwards to MainActivity. There's no back stack
|
||||
// to pop in our caller's scope.
|
||||
}
|
||||
|
||||
override fun <T : Route> popUpTo(
|
||||
route: Route,
|
||||
klass: KClass<T>,
|
||||
) {
|
||||
// No-op for the same reason as popBack().
|
||||
}
|
||||
|
||||
private fun bounce(uri: String) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(uri))
|
||||
.setClass(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
runCatching { context.startActivity(intent) }
|
||||
.onFailure { t -> Log.w("BouncingIntentNav") { "could not bounce to MainActivity: ${t.message}" } }
|
||||
}
|
||||
|
||||
private fun routeToBouncingUri(route: Route): String? =
|
||||
when (route) {
|
||||
is Route.Profile -> {
|
||||
"nostr:" + NPub.create(route.id)
|
||||
}
|
||||
|
||||
is Route.Note -> {
|
||||
"nostr:" + NEvent.create(route.id, null, null, null)
|
||||
}
|
||||
|
||||
is Route.EventRedirect -> {
|
||||
"nostr:" + NEvent.create(route.id, null, null, null)
|
||||
}
|
||||
|
||||
is Route.Hashtag -> {
|
||||
"nostr:hashtag?id=" + route.hashtag
|
||||
}
|
||||
|
||||
is Route.LiveActivityChannel -> {
|
||||
"nostr:" + NAddress.create(route.kind, route.pubKeyHex, route.dTag, null)
|
||||
}
|
||||
|
||||
is Route.Community -> {
|
||||
"nostr:" + NAddress.create(route.kind, route.pubKeyHex, route.dTag, null)
|
||||
}
|
||||
|
||||
is Route.PublicChatChannel -> {
|
||||
"nostr:" + NEvent.create(route.id, null, ChannelCreateEvent.KIND, null)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+212
-64
@@ -23,101 +23,249 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
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.RefreshingChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
|
||||
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.screen.loggedIn.chats.feed.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
|
||||
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. Reuses the same `ChannelFeedViewModel` +
|
||||
* `RefreshingChatroomFeedView` + `EditFieldRow` stack that powers
|
||||
* NIP-53 live-stream chat (`LiveActivityChannelView`) so a Nest's
|
||||
* kind-1311 transcript renders with full Amethyst chat features —
|
||||
* mentions, embedded media, reply previews, draft handling, NIP-21
|
||||
* link rendering, etc. — instead of the bare-text v1 list.
|
||||
* 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.
|
||||
*
|
||||
* The kind-30312 address registers a [com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel]
|
||||
* inside [LocalCache.liveChatChannels] (see `LocalCache.consume(LiveActivitiesChatMessageEvent)`),
|
||||
* so we can hand the channel straight to `ChannelFeedViewModel`
|
||||
* without a kind-30311-specific path. The relay subscription that
|
||||
* populates the channel is the one started by
|
||||
* `RoomChatFilterAssemblerSubscription` in [NestActivityContent];
|
||||
* we don't need a second `ChannelFilterAssemblerSubscription` here
|
||||
* (it would be a no-op for kind-30312 channels because their
|
||||
* `LiveActivitiesChannel.info` is null and `relays()` is empty).
|
||||
* 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.
|
||||
*
|
||||
* Embedded inside the room screen's verticalScroll Column with a
|
||||
* fixed height — a LazyColumn child cannot share its parent's
|
||||
* scroll. The screen-level layout intentionally stays unchanged
|
||||
* (top section is fine; only the chat surface migrates).
|
||||
* Navigation: the activity has no Compose NavHost in scope. Taps that
|
||||
* resolve to a NIP-19 entity (profile, quoted note, hashtag, addressable
|
||||
* channel) are dispatched through [BouncingIntentNav] — a `nostr:` URI
|
||||
* Intent at MainActivity. Anything that doesn't have a `nostr:` URI is
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NestChatPanel(
|
||||
event: MeetingSpaceEvent,
|
||||
viewModel: NestViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val channel =
|
||||
val messages by viewModel.chat.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val nav = remember(context, scope) { BouncingIntentNav(context, scope) }
|
||||
|
||||
val roomATag =
|
||||
remember(event) {
|
||||
LocalCache.getOrCreateLiveChannel(event.address())
|
||||
ATag(
|
||||
kind = event.kind,
|
||||
pubKeyHex = event.pubKey,
|
||||
dTag = event.dTag(),
|
||||
relay = null,
|
||||
)
|
||||
}
|
||||
|
||||
val feedViewModel: ChannelFeedViewModel =
|
||||
viewModel(
|
||||
key = event.address().toValue() + "NestChannelFeedViewModel",
|
||||
factory =
|
||||
ChannelFeedViewModel.Factory(
|
||||
channel,
|
||||
accountViewModel.account,
|
||||
),
|
||||
)
|
||||
|
||||
val newPostModel: ChannelNewMessageViewModel =
|
||||
viewModel(key = event.address().toValue() + "NestChannelNewMessageViewModel")
|
||||
newPostModel.init(accountViewModel)
|
||||
newPostModel.load(channel)
|
||||
|
||||
WatchLifecycleAndUpdateModel(feedViewModel)
|
||||
|
||||
// The NestActivity is a separate Android Activity; it has no
|
||||
// Compose nav graph. Pass an EmptyNav so the chat composables
|
||||
// render correctly (their navigation taps become no-ops). A
|
||||
// future patch can plumb a deep-link-bouncing nav if we want
|
||||
// profile / quote-note taps inside chat to land in the main app.
|
||||
val nav = remember { EmptyNav() }
|
||||
val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" }
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(NEST_CHAT_PANEL_HEIGHT)) {
|
||||
RefreshingChatroomFeedView(
|
||||
feedContentState = feedViewModel.feedState,
|
||||
if (messages.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_chat_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
)
|
||||
} else {
|
||||
NestChatMessageList(
|
||||
messages = messages,
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
NestChatComposer(
|
||||
roomATag = roomATag,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NestChatMessageList(
|
||||
messages: List<LiveActivitiesChatMessageEvent>,
|
||||
routeForLastRead: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: BouncingIntentNav,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Auto-scroll to the newest message ONLY when the user is already
|
||||
// pinned near the bottom — otherwise a new message yanks them away
|
||||
// from the history they're scrolling. With reverseLayout=true the
|
||||
// bottom of the screen is index 0, so "pinned" = the first visible
|
||||
// item is index 0 or 1 (one row of tolerance for partial scroll).
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { messages.size }.collect { size ->
|
||||
if (size > 0 && listState.firstVisibleItemIndex <= 1) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// viewModel.chat is sorted ascending by created_at (oldest first).
|
||||
// reverseLayout=true puts index 0 at the bottom, so reverse the
|
||||
// list here to keep newest-at-bottom rendering — same behavior
|
||||
// LiveStream chat ships in ChatFeedLoaded.
|
||||
val reversed = remember(messages) { messages.asReversed() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
) {
|
||||
items(
|
||||
items = reversed,
|
||||
key = { it.id },
|
||||
contentType = { it.kind },
|
||||
) { event ->
|
||||
val note = remember(event.id) { LocalCache.getOrCreateNote(event.id) }
|
||||
ChatroomMessageCompose(
|
||||
baseNote = note,
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
routeForLastRead = "Channel/${channel.address.toValue()}",
|
||||
avoidDraft = newPostModel.draftTag,
|
||||
onWantsToReply = newPostModel::reply,
|
||||
onWantsToEditDraft = newPostModel::editFromDraft,
|
||||
onWantsToReply = NEST_CHAT_NO_OP_NOTE,
|
||||
onWantsToEditDraft = NEST_CHAT_NO_OP_NOTE,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
EditFieldRow(
|
||||
channelScreenModel = newPostModel,
|
||||
accountViewModel = accountViewModel,
|
||||
onSendNewMessage = feedViewModel.feedState::sendToTop,
|
||||
nav = nav,
|
||||
}
|
||||
}
|
||||
|
||||
@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_PANEL_HEIGHT = 420.dp
|
||||
|
||||
private val NEST_CHAT_NO_OP_NOTE: (com.vitorpamplona.amethyst.model.Note) -> Unit = {}
|
||||
|
||||
+1
@@ -325,6 +325,7 @@ internal fun NestFullScreen(
|
||||
|
||||
NestChatPanel(
|
||||
event = event,
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user