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 940a15f67..32d91062c 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 @@ -51,8 +51,8 @@ import com.vitorpamplona.amethyst.model.Note 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.ChatroomMessageCompose -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.nests.room.send.NestEditFieldRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.send.NestNewMessageViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -63,45 +63,21 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv * per-message renderer that NIP-53 live-stream chat uses * ([ChatroomMessageCompose]). * - * Composer is the same [EditFieldRow] used by every other public-chat - * surface in the app, so we get @-mention picker, file/image/video - * upload, reply preview, draft auto-save, emoji suggestions, content - * warnings, expiration, geohash, and zap-split forwarding for free. + * The composer is owned by a separate [NestNewMessageViewModel] — + * scoped to *editing and sending* chat messages only — so we get + * @-mention picker, file/image/video upload, reply preview, NIP-37 + * draft auto-save, emoji suggestions, content warnings, expiration, + * geohash, and zap-split forwarding for the nest chat field. The + * message list itself is still driven from the [NestViewModel] so + * presence / reactions / chat all share a single lifecycle. * - * To stay aligned with the rest of the app, the composer is backed by - * a [ChannelNewMessageViewModel] loaded with a [LiveActivitiesChannel] - * keyed by the meeting space's address. `ChannelNewMessageViewModel. - * createTemplate()` already handles the `LiveActivitiesChannel` → - * `LiveActivitiesChatMessageEvent` path: when `channel.info` is null - * (we never populate it for kind-30312 — it's typed to the kind-30311 - * `LiveActivitiesEvent`), it falls through to the - * `LiveActivitiesChatMessageEvent.message(message, channel.toATag())` - * branch, which produces exactly the same event the original slim - * composer was emitting. - * - * Data flow: messages still arrive via the existing nest pipeline - * (`RoomChatFilterAssemblerSubscription` → `NestViewModel.onChatEvent` - * → `viewModel.chat`). The composer emits via - * `account.signAndSendPrivately(template, channelRelays)` inside - * `ChannelNewMessageViewModel.sendPostSync`, which lands on the same - * relays the listener subscribes to, so the user sees their own - * message immediately when it round-trips. Drafts are stored as - * NIP-37 wrap events through the standard account draft pipeline — - * they do not pollute `viewModel.chat` because that flow only accepts - * kind-1311 events. - * - * 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. - * - * The composer's default back-press handler from [EditFieldRow] would - * try to `nav.popBack()` (a no-op on [BouncingIntentNav]) and clear - * the field — both wrong inside an Activity-scoped chat panel where - * back means "leave the room". We pass `interceptBackPress = false` - * so system back continues to flow to the activity. + * 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. */ @Composable internal fun ColumnScope.NestChatPanel( @@ -115,16 +91,12 @@ internal fun ColumnScope.NestChatPanel( val scope = rememberCoroutineScope() val nav = remember(context, scope) { BouncingIntentNav(context, scope) } - val channel = - remember(event) { - accountViewModel.checkGetOrCreateLiveActivityChannel(event.address()) - } val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" } - val channelScreenModel: ChannelNewMessageViewModel = + val nestScreenModel: NestNewMessageViewModel = composeViewModel(key = "Nest/${event.address().toValue()}") - channelScreenModel.init(accountViewModel) - channelScreenModel.load(channel) + nestScreenModel.init(accountViewModel) + nestScreenModel.load(event) Column(modifier = modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) { @@ -141,19 +113,18 @@ internal fun ColumnScope.NestChatPanel( routeForLastRead = routeForLastRead, accountViewModel = accountViewModel, nav = nav, - onWantsToReply = channelScreenModel::reply, + onWantsToReply = nestScreenModel::reply, ) } } Spacer(Modifier.height(8.dp)) - EditFieldRow( - channelScreenModel = channelScreenModel, + NestEditFieldRow( + nestScreenModel = nestScreenModel, accountViewModel = accountViewModel, onSendNewMessage = {}, nav = nav, - interceptBackPress = false, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestEditFieldRow.kt new file mode 100644 index 000000000..2b89f1a7d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestEditFieldRow.kt @@ -0,0 +1,167 @@ +/* + * 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.screen.loggedIn.nests.room.send + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +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.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.FlowPreview + +/** + * Mirror of `EditFieldRow` for the nest-room composer. Uses + * [NestNewMessageViewModel] which is scoped to a single + * [com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent] + * and only emits kind-1311 chat messages. + * + * Renders, top-to-bottom: stripping-failure dialog, reply preview, + * file-upload dialog, @-mention picker, emoji suggestions, and the + * text field with gallery picker + send button. + * + * Differences from the channel `EditFieldRow`: + * - No `BackHandler`: the nest chat panel lives inside an Activity + * that owns its own back behaviour (leave room / enter PiP). The + * composer's draft auto-save runs on every text change via + * [NestNewMessageViewModel.draftTag], so there is nothing to flush + * on back press. + */ +@OptIn(FlowPreview::class) +@Composable +fun NestEditFieldRow( + nestScreenModel: NestNewMessageViewModel, + accountViewModel: AccountViewModel, + onSendNewMessage: suspend () -> Unit, + nav: INav, +) { + StrippingFailureDialog(nestScreenModel.strippingFailureConfirmation) + + nestScreenModel.replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { + nestScreenModel.clearReply() + } + } + + nestScreenModel.uploadState?.let { uploading -> + uploading.multiOrchestrator?.let { + NestFileUploadDialog( + nestScreenModel = nestScreenModel, + state = uploading, + onUpload = onSendNewMessage, + onCancel = uploading::reset, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + Column( + modifier = EditFieldModifier, + ) { + nestScreenModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + nestScreenModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, + ) + } + + nestScreenModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + nestScreenModel::autocompleteWithEmoji, + nestScreenModel::autocompleteWithEmojiUrl, + SuggestionListDefaultHeightChat, + ) + } + + ThinPaddingTextField( + state = nestScreenModel.message, + onTextChanged = { nestScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringRes(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + ThinSendButton( + isActive = + nestScreenModel.message.text.isNotBlank() && !nestScreenModel.isUploadingImage, + modifier = EditFieldTrailingIconModifier, + ) { + nestScreenModel.sendPost(onSendNewMessage) + } + }, + leadingIcon = { + SelectFromGallery( + isUploading = nestScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.height(32.dp).padding(start = 2.dp), + onImageChosen = nestScreenModel::pickedMedia, + ) + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestFileUploadDialog.kt new file mode 100644 index 000000000..bd73c4dcb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestFileUploadDialog.kt @@ -0,0 +1,108 @@ +/* + * 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.screen.loggedIn.nests.room.send + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.theme.Size34dp + +/** + * Mirror of `ChannelFileUploadDialog` for nest chat. Wraps the + * generic [ChatFileUploadDialog] with a header showing the host + * avatar + the room name pulled from the [NestNewMessageViewModel]'s + * loaded [com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent]. + */ +@Composable +fun NestFileUploadDialog( + nestScreenModel: NestNewMessageViewModel, + state: ChatFileUploadState, + onUpload: suspend () -> Unit, + onCancel: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val room = nestScreenModel.room ?: return + val context = LocalContext.current + + val host = remember(room.pubKey) { LocalCache.getOrCreateUser(room.pubKey) } + val title = room.room().orEmpty() + + ChatFileUploadDialog( + state, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture( + user = host, + size = Size34dp, + accountViewModel = accountViewModel, + nav = nav, + ) + + Column( + modifier = + Modifier + .padding(start = 10.dp) + .height(35.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + }, + upload = { + nestScreenModel.upload( + onError = accountViewModel.toastManager::toast, + context = context, + onceUploaded = onUpload, + ) + + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) + }, + onCancel, + accountViewModel, + nav, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestNewMessageViewModel.kt index c97c4d7a6..b60857cbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestNewMessageViewModel.kt @@ -297,7 +297,11 @@ open class NestNewMessageViewModel : val version = draftTag.current cancel() - accountViewModel.account.signAndSendPrivately(template, emptySet()) + // Broadcast to the user's default relays — the nest has no + // dedicated relay set the way a public-chat channel does, so + // this matches what the original slim composer was doing via + // `account.signAndComputeBroadcast(...)`. + accountViewModel.account.signAndComputeBroadcast(template) accountViewModel.viewModelScope.launch(Dispatchers.IO) { accountViewModel.account.deleteDraftIgnoreErrors(version) }