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:
+75
-180
@@ -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
|
||||||
|
|||||||
+1
-8
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user