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:
Claude
2026-04-27 15:16:36 +00:00
parent bdb82f0631
commit 462a1c446c
3 changed files with 370 additions and 64 deletions
@@ -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
}
}
}
@@ -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 = {}
@@ -325,6 +325,7 @@ internal fun NestFullScreen(
NestChatPanel(
event = event,
viewModel = viewModel,
accountViewModel = accountViewModel,
modifier = Modifier.padding(top = 12.dp),
)