diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt new file mode 100644 index 000000000..e79394c94 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/BouncingIntentNav.kt @@ -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 popUpTo( + route: Route, + klass: KClass, + ) { + // 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 + } + } +} 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 6bf97d59e..830fffe36 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 @@ -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, + 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 = {} 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 1f61847b5..27f8a4cc3 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 @@ -325,6 +325,7 @@ internal fun NestFullScreen( NestChatPanel( event = event, + viewModel = viewModel, accountViewModel = accountViewModel, modifier = Modifier.padding(top = 12.dp), )