feat(nests): migrate chat panel to LiveStream chat stack

The hand-rolled NestChatPanel — bare LazyColumn of avatar + name +
plain-text rows + an OutlinedTextField composer — is replaced by the
same `RefreshingChatroomFeedView` + `EditFieldRow` stack that
`LiveActivityChannelView` uses for kind:30311 streaming chats.

The kind-30312 address already registers a `LiveActivitiesChannel`
inside `LocalCache.liveChatChannels` (via
`LocalCache.consume(LiveActivitiesChatMessageEvent)`), so handing the
channel to `ChannelFeedViewModel.Factory` is enough — no kind-30311-
specific path needed. The kind-1311 relay subscription stays where it
is in `NestActivityContent.RoomChatFilterAssemblerSubscription`, which
populates `channel.notes` exactly the same way.

Net effect for the user: chat now renders with the rest of Amethyst's
chat features — NIP-21 mention rendering, embedded images / videos,
reply previews, draft handling, mention/file pickers, replies — instead
of plain bodies in a 220dp box.

Layout intentionally unchanged elsewhere: the chat panel keeps its
embedded place at the bottom of NestFullScreen's verticalScroll Column
(top section is good per user). The chat list gets a fixed 420dp height
because a vertically-scrollable LazyColumn child cannot share its
parent's verticalScroll.

Nav: the activity has no Compose nav graph, so an `EmptyNav()` is
passed. Visual rendering is correct; in-message tap navigation (profile
tap, embedded note open, …) is a no-op until we plumb a deep-link-
bouncing INav. Outside scope for this commit.

`NestViewModel.chat` StateFlow is now unused by the panel (kept for now
to avoid touching the VM in the same change; cleanup is a follow-up).

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
Claude
2026-04-27 14:47:30 +00:00
parent c13d844416
commit bdb82f0631
2 changed files with 74 additions and 186 deletions
@@ -20,209 +20,104 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import androidx.lifecycle.viewmodel.compose.viewModel
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import kotlinx.coroutines.launch
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.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
/**
* Live-chat panel (T1 #2) — renders the kind-1311 transcript for
* the room and provides a send field. Messages flow from
* [NestViewModel.chat] (populated by the LocalCache observer
* in [NestActivityContent]); send routes through
* `account.signAndComputeBroadcast` directly so the VM stays free
* of platform / signing dependencies.
* 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.
*
* 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).
*
* 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).
*/
@Composable
internal fun NestChatPanel(
roomATag: ATag,
viewModel: NestViewModel,
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val messages by viewModel.chat.collectAsState()
val listState = rememberLazyListState()
val channel =
remember(event) {
LocalCache.getOrCreateLiveChannel(event.address())
}
// Only auto-scroll when the user is already pinned near the
// bottom — otherwise a new message yanks them away from the
// history they're scrolling through. Standard chat-app pattern.
val isAtBottom by remember(messages.size) {
derivedStateOf {
val lastIdx = messages.lastIndex
if (lastIdx < 0) {
true
} else {
listState.firstVisibleItemIndex >= (lastIdx - PINNED_TO_BOTTOM_TOLERANCE)
}
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty() && isAtBottom) {
listState.animateScrollToItem(messages.size - 1)
}
}
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() }
Column(modifier = modifier.fillMaxWidth()) {
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),
Box(modifier = Modifier.fillMaxWidth().height(NEST_CHAT_PANEL_HEIGHT)) {
RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.address.toValue()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().height(220.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(messages, key = { it.id }) { msg ->
ChatRow(msg, accountViewModel)
}
}
}
Spacer(Modifier.height(8.dp))
ChatComposer(roomATag, accountViewModel)
}
}
@Composable
private fun ChatRow(
msg: LiveActivitiesChatMessageEvent,
accountViewModel: AccountViewModel,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
ClickableUserPicture(
baseUserHex = msg.pubKey,
size = Size35dp,
EditFieldRow(
channelScreenModel = newPostModel,
accountViewModel = accountViewModel,
onSendNewMessage = feedViewModel.feedState::sendToTop,
nav = nav,
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.fillMaxWidth()) {
// Same author-name pattern the rest of Amethyst's chat
// surfaces use — LoadUser kicks the metadata fetch and
// UsernameDisplay observes its kind-0 flow, falling back
// to a truncated npub while the metadata is in flight or
// missing. Replaces the "first 8 hex chars of pubkey"
// placeholder the v1 chat panel shipped with.
LoadUser(baseUserHex = msg.pubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
textColor = MaterialTheme.colorScheme.primary,
accountViewModel = accountViewModel,
)
}
}
Text(
text = msg.content,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun ChatComposer(
roomATag: ATag,
accountViewModel: AccountViewModel,
) {
var draft by remember { mutableStateOf("") }
var isSending by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringRes(R.string.nest_chat_placeholder)) },
singleLine = false,
maxLines = 4,
enabled = !isSending,
)
Spacer(Modifier.width(8.dp))
TextButton(
enabled = draft.isNotBlank() && !isSending,
onClick = {
val toSend = draft.trim()
if (toSend.isEmpty()) return@TextButton
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.
draft = ""
} 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,
)
}
}
},
) {
Text(stringRes(R.string.nest_chat_send))
}
}
}
private const val PINNED_TO_BOTTOM_TOLERANCE = 1
private val NEST_CHAT_PANEL_HEIGHT = 420.dp
@@ -324,14 +324,7 @@ internal fun NestFullScreen(
}
NestChatPanel(
roomATag =
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
),
viewModel = viewModel,
event = event,
accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 12.dp),
)