From bdb82f0631dd18b5876e2417dc8c835039d72bfe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 14:47:30 +0000 Subject: [PATCH] feat(nests): migrate chat panel to LiveStream chat stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../loggedIn/nests/room/NestChatPanel.kt | 251 +++++------------- .../loggedIn/nests/room/NestFullScreen.kt | 9 +- 2 files changed, 74 insertions(+), 186 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt index 465d9fcbe..6bf97d59e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestChatPanel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt index 6124e6d2c..1f61847b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestFullScreen.kt @@ -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), )