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 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import kotlinx.coroutines.launch
/** /**
* Live-chat panel (T1 #2) — renders the kind-1311 transcript for * In-room chat panel. Reuses the same `ChannelFeedViewModel` +
* the room and provides a send field. Messages flow from * `RefreshingChatroomFeedView` + `EditFieldRow` stack that powers
* [NestViewModel.chat] (populated by the LocalCache observer * NIP-53 live-stream chat (`LiveActivityChannelView`) so a Nest's
* in [NestActivityContent]); send routes through * kind-1311 transcript renders with full Amethyst chat features —
* `account.signAndComputeBroadcast` directly so the VM stays free * mentions, embedded media, reply previews, draft handling, NIP-21
* of platform / signing dependencies. * 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 @Composable
internal fun NestChatPanel( internal fun NestChatPanel(
roomATag: ATag, event: MeetingSpaceEvent,
viewModel: NestViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val messages by viewModel.chat.collectAsState() val channel =
val listState = rememberLazyListState() 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)
}
} }
Column(modifier = modifier.fillMaxWidth()) { val feedViewModel: ChannelFeedViewModel =
if (messages.isEmpty()) { viewModel(
Text( key = event.address().toValue() + "NestChannelFeedViewModel",
text = stringRes(R.string.nest_chat_empty), factory =
style = MaterialTheme.typography.bodySmall, ChannelFeedViewModel.Factory(
color = MaterialTheme.colorScheme.onSurfaceVariant, channel,
modifier = Modifier.padding(vertical = 12.dp), accountViewModel.account,
)
} 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,
accountViewModel = accountViewModel,
)
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 val newPostModel: ChannelNewMessageViewModel =
if (result.isSuccess) { viewModel(key = event.address().toValue() + "NestChannelNewMessageViewModel")
// Clear ONLY on success so a network failure newPostModel.init(accountViewModel)
// doesn't lose the user's text — they can retry newPostModel.load(channel)
// without retyping.
draft = "" WatchLifecycleAndUpdateModel(feedViewModel)
} else {
val why = // The NestActivity is a separate Android Activity; it has no
result.exceptionOrNull()?.message // Compose nav graph. Pass an EmptyNav so the chat composables
?: result.exceptionOrNull()?.let { it::class.simpleName } // render correctly (their navigation taps become no-ops). A
?: "unknown error" // future patch can plumb a deep-link-bouncing nav if we want
accountViewModel.toastManager.toast( // profile / quote-note taps inside chat to land in the main app.
R.string.nest_chat_send_failed_title, val nav = remember { EmptyNav() }
why,
user = null, Column(modifier = modifier.fillMaxWidth()) {
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,
) )
} }
} Spacer(Modifier.height(8.dp))
}, EditFieldRow(
) { channelScreenModel = newPostModel,
Text(stringRes(R.string.nest_chat_send)) accountViewModel = accountViewModel,
} onSendNewMessage = feedViewModel.feedState::sendToTop,
nav = nav,
)
} }
} }
private const val PINNED_TO_BOTTOM_TOLERANCE = 1 private val NEST_CHAT_PANEL_HEIGHT = 420.dp
@@ -324,14 +324,7 @@ internal fun NestFullScreen(
} }
NestChatPanel( NestChatPanel(
roomATag = event = event,
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
),
viewModel = viewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
) )