From 46cfab4fa1f42c382491a8b94f4f7d00cebac887 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 22:09:45 +0000 Subject: [PATCH 1/3] feat(nests): use shared EditFieldRow for nest chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the in-room chat composer to ChannelNewMessageViewModel + EditFieldRow so nest chat picks up @-mention picker, file/image/ video upload, reply preview, NIP-37 draft auto-save, emoji suggestions, content warnings, expiration, geohash, and zap- split forwarding — the same affordances every other public chat surface has. The composer is backed by a LiveActivitiesChannel keyed by the meeting space's address. ChannelNewMessageViewModel.createTemplate() already handles the LiveActivitiesChannel branch: when channel.info is null (which it always is for kind-30312, since info is typed to the kind-30311 LiveActivitiesEvent), it falls through to LiveActivitiesChatMessageEvent.message(post, channel.toATag()) — the exact event shape the previous slim composer was emitting. EditFieldRow gains an interceptBackPress flag so nest can opt out of the channel-screen back-handler that would clear the field and call nav.popBack() (a no-op on BouncingIntentNav). --- .../chats/publicChannels/send/EditFieldRow.kt | 13 +- .../loggedIn/nests/room/NestChatPanel.kt | 161 ++++++------------ 2 files changed, 60 insertions(+), 114 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index c9e63c90f..5758215b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -63,13 +63,16 @@ fun EditFieldRow( accountViewModel: AccountViewModel, onSendNewMessage: suspend () -> Unit, nav: INav, + interceptBackPress: Boolean = true, ) { - BackHandler { - accountViewModel.launchSigner { - channelScreenModel.sendDraftSync() - channelScreenModel.cancel() + if (interceptBackPress) { + BackHandler { + accountViewModel.launchSigner { + channelScreenModel.sendDraftSync() + channelScreenModel.cancel() + } + nav.popBack() } - nav.popBack() } StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation) 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 c0eba15ed..940a15f67 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 @@ -31,59 +31,64 @@ 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 as composeViewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +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.utils.ThinSendButton +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.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. 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. + * ([ChatroomMessageCompose]). * - * 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. + * 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. + * + * 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 @@ -92,10 +97,11 @@ import kotlinx.coroutines.launch * 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. + * 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. */ @Composable internal fun ColumnScope.NestChatPanel( @@ -109,17 +115,17 @@ internal fun ColumnScope.NestChatPanel( val scope = rememberCoroutineScope() val nav = remember(context, scope) { BouncingIntentNav(context, scope) } - val roomATag = + val channel = remember(event) { - ATag( - kind = event.kind, - pubKeyHex = event.pubKey, - dTag = event.dTag(), - relay = null, - ) + accountViewModel.checkGetOrCreateLiveActivityChannel(event.address()) } val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" } + val channelScreenModel: ChannelNewMessageViewModel = + composeViewModel(key = "Nest/${event.address().toValue()}") + channelScreenModel.init(accountViewModel) + channelScreenModel.load(channel) + Column(modifier = modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) { if (messages.isEmpty()) { @@ -135,15 +141,19 @@ internal fun ColumnScope.NestChatPanel( routeForLastRead = routeForLastRead, accountViewModel = accountViewModel, nav = nav, + onWantsToReply = channelScreenModel::reply, ) } } Spacer(Modifier.height(8.dp)) - NestChatComposer( - roomATag = roomATag, + EditFieldRow( + channelScreenModel = channelScreenModel, accountViewModel = accountViewModel, + onSendNewMessage = {}, + nav = nav, + interceptBackPress = false, ) } } @@ -154,6 +164,7 @@ private fun NestChatMessageList( routeForLastRead: String, accountViewModel: AccountViewModel, nav: BouncingIntentNav, + onWantsToReply: (Note) -> Unit, ) { val listState = rememberLazyListState() @@ -192,79 +203,11 @@ private fun NestChatMessageList( routeForLastRead = routeForLastRead, accountViewModel = accountViewModel, nav = nav, - onWantsToReply = NEST_CHAT_NO_OP_NOTE, + onWantsToReply = onWantsToReply, onWantsToEditDraft = NEST_CHAT_NO_OP_NOTE, ) } } } -@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_NO_OP_NOTE: (com.vitorpamplona.amethyst.model.Note) -> Unit = {} +private val NEST_CHAT_NO_OP_NOTE: (Note) -> Unit = {} From 57ebded68bea7b5a0fdf5912a53ecc835a7853c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 22:31:46 +0000 Subject: [PATCH 2/3] wip(nests): start copying ChannelNewMessageViewModel into nest package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot from sharing ChannelNewMessageViewModel (previous commit) to a literal per-nest copy as requested. State of this commit (incomplete — DO NOT MERGE): - EditFieldRow.kt: reverted to original (no interceptBackPress flag) - NestNewMessageViewModel.kt: new file, copy of ChannelNewMessageViewModel narrowed to MeetingSpaceEvent + LiveActivitiesChatMessageEvent only Still TODO before this branch is usable: - Copy EditFieldRow into nests/room/send/NestEditFieldRow.kt - Copy ChannelFileUploadDialog into nests/room/send/NestFileUploadDialog.kt - Rewire NestChatPanel to use these copies (it currently still calls ChannelNewMessageViewModel + EditFieldRow from the previous commit's approach, which won't compile against the reverted EditFieldRow signature once NestChatPanel is also reverted) User asked to stop mid-work — committing as WIP so the tree is clean. --- .../chats/publicChannels/send/EditFieldRow.kt | 13 +- .../room/send/NestNewMessageViewModel.kt | 642 ++++++++++++++++++ 2 files changed, 647 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestNewMessageViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 5758215b4..c9e63c90f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -63,16 +63,13 @@ fun EditFieldRow( accountViewModel: AccountViewModel, onSendNewMessage: suspend () -> Unit, nav: INav, - interceptBackPress: Boolean = true, ) { - if (interceptBackPress) { - BackHandler { - accountViewModel.launchSigner { - channelScreenModel.sendDraftSync() - channelScreenModel.cancel() - } - nav.popBack() + BackHandler { + accountViewModel.launchSigner { + channelScreenModel.sendDraftSync() + channelScreenModel.cancel() } + nav.popBack() } StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation) 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 new file mode 100644 index 000000000..c97c4d7a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestNewMessageViewModel.kt @@ -0,0 +1,642 @@ +/* + * 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 android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.currentWord +import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor +import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration +import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.notify +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Composer-side ViewModel for nest-room (kind-30312 MeetingSpace) chat. + * Mirrors `ChannelNewMessageViewModel` so the chat field inside the + * audio room gets full @-mention picker, file/image/video upload, + * reply preview, NIP-37 draft auto-save, emoji suggestions, content + * warnings, expiration, geohash, and zap-split forwarding. + * + * Kept as a separate class (not parameterising the channel one) + * because every event built here is exclusively a kind-1311 + * `LiveActivitiesChatMessageEvent` anchored to a `MeetingSpaceEvent`'s + * a-tag — a single, narrow path with no `PublicChatChannel` / + * `EphemeralChatChannel` branches to maintain. + */ +@Stable +open class NestNewMessageViewModel : + ViewModel(), + ILocationGrabber, + IExpiration { + val draftTag = DraftTagState() + + init { + viewModelScope.launch(Dispatchers.IO) { + draftTag.versions.collectLatest { + // don't save the first + if (it > 0) { + accountViewModel.launchSigner { + sendDraftSync() + } + } + } + } + } + + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + var room: MeetingSpaceEvent? = null + + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + + // Stripping failure dialog + val strippingFailureConfirmation = SuspendableConfirmation() + + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>(emptyList()) + + val message = TextFieldState() + var urlPreview by mutableStateOf(null) + val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false + val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false + + var userSuggestions: UserSuggestionState? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + var emojiSuggestions: EmojiSuggestionState? = null + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf>(SplitBuilder()) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + var contentWarningDescription by mutableStateOf("") + + // Expiration Date (NIP-40) + var wantsExpirationDate by mutableStateOf(false) + override var expirationDate by mutableLongStateOf(TimeUtils.oneDayAhead()) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + var zapRaiserAmount by mutableStateOf(null) + + fun lnAddress(): String? = account.userProfile().lnAddress() + + fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null + + fun user(): User = account.userProfile() + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + this.canAddInvoice = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.userSuggestions?.reset() + this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + open fun load(room: MeetingSpaceEvent) { + this.room = room + } + + open fun reply(replyNote: Note) { + replyTo.value = replyNote + draftTag.newVersion() + } + + fun clearReply() { + replyTo.value = null + draftTag.newVersion() + } + + open fun editFromDraft(draft: Note) { + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftWrapEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag.set(oldTag) + } + loadFromDraft(innerNote) + } + } + } + } + + private fun loadFromDraft(draft: Note) { + val draftEvent = draft.event ?: return + + val localForwardZapTo = draftEvent.tags.zapSplitSetup() + val totalWeight = localForwardZapTo.sumOf { it.weight } + forwardZapTo = SplitBuilder() + localForwardZapTo.forEach { + if (it is ZapSplitSetup) { + val user = LocalCache.getOrCreateUser(it.pubKeyHex) + forwardZapTo.addItem(user, (it.weight / totalWeight).toFloat()) + } + // don't support edditing old-style splits. + } + forwardZapToEditting = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + contentWarningDescription = draftEvent.contentWarningReason() ?: "" + + val draftExpiration = draftEvent.expiration() + wantsExpirationDate = draftExpiration != null + expirationDate = draftExpiration ?: TimeUtils.oneDayAhead() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount = null + if (zapraiser != null) { + zapRaiserAmount = zapraiser + } + + if (forwardZapTo.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + if (draftEvent as? LiveActivitiesChatMessageEvent != null) { + val replyId = draftEvent.reply()?.eventId + if (replyId != null) { + replyTo.value = accountViewModel.checkGetOrCreateNote(replyId) + } + } + + message.setTextAndPlaceCursorAtEnd(draftEvent.content) + + iMetaAttachments.addAll(draftEvent.imetas()) + + urlPreview = findUrlInMessage() + } + + fun sendPost(onDone: suspend () -> Unit) { + accountViewModel.launchSigner { + sendPostSync() + onDone() + } + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + + val version = draftTag.current + cancel() + + accountViewModel.account.signAndSendPrivately(template, emptySet()) + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.account.deleteDraftIgnoreErrors(version) + } + } + + suspend fun sendDraftSync() { + if (message.text.toString().isBlank()) { + account.deleteDraftIgnoreErrors(draftTag.current) + } else { + val attachments = mutableSetOf() + nip95attachments.forEach { + attachments.add(it.first) + attachments.add(it.second) + } + + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments) + } + } + + fun pickedMedia(list: ImmutableList) { + uploadState?.load(list) + } + + fun upload( + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: suspend () -> Unit, + ) = try { + uploadUnsafe(onError, context, onceUploaded) + } catch (_: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + fun uploadUnsafe( + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: suspend () -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + val uploadState = uploadState ?: return@launch + + val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch + + uploadState.mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) + + val results = + myMultiOrchestrator.upload( + uploadState.caption, + uploadState.contentWarningReason, + MediaCompressor.intToCompressorQuality(uploadState.mediaQualitySlider), + uploadState.selectedServer, + account, + context, + stripMetadata = uploadState.stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + + if (results.allGood) { + val urls = + results.successful.mapNotNull { upload -> + if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason) + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } + + note?.toNostrUri() + } else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason) + + upload.result.url + } else { + null + } + } + + message.insertUrlAtCursor(urls.joinToString(" ")) + urlPreview = findUrlInMessage() + + uploadState.reset() + onceUploaded() + draftTag.newVersion() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + uploadState.mediaUploadTracker.finishUpload() + } + } + + private fun roomATag(): ATag? { + val r = room ?: return null + return ATag( + kind = r.kind, + pubKeyHex = r.pubKey, + dTag = r.dTag(), + relay = null, + ) + } + + private suspend fun createTemplate(): EventTemplate? { + val activity = roomATag() ?: return null + val messageText = message.text.toString() + val tagger = + NewMessageTagger( + message = messageText, + pTags = listOfNotNull(replyTo.value?.author), + eTags = listOfNotNull(replyTo.value), + dao = accountViewModel, + ) + tagger.run() + + val urls = findURLs(messageText) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + + val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + + val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null + val localExpirationDate = if (wantsExpirationDate) expirationDate else null + + val replyingToEvent = replyTo.value?.toEventHint() + + return if (replyingToEvent != null) { + LiveActivitiesChatMessageEvent.reply(tagger.message, replyingToEvent) { + notify(replyingToEvent.toPTag()) + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + + geoHash?.let { geohash(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else { + LiveActivitiesChatMessageEvent.message(tagger.message, activity) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + + geoHash?.let { geohash(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } + } + } + + open fun cancel() { + draftTag.rotate() + + message.setTextAndPlaceCursorAtEnd("") + + replyTo.value = null + + urlPreview = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + contentWarningDescription = "" + wantsToAddGeoHash = false + + forwardZapTo = SplitBuilder() + forwardZapToEditting = TextFieldValue("") + + userSuggestions?.reset() + userSuggestionsMainMessage = null + + uploadState?.reset() + + iMetaAttachments.reset() + + emojiSuggestions?.reset() + } + + open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text.toString()).withScheme.firstOrNull() + + open fun addToMessage(it: String) { + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() + } + + open fun onMessageChanged() { + urlPreview = findUrlInMessage() + + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestionsMainMessage = null + userSuggestions?.reset() + } + + emojiSuggestions?.processCurrentWord(lastWord) + } + + draftTag.newVersion() + } + + open fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + userSuggestions?.processCurrentWord(lastWord) + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestions?.let { + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + it.replaceCurrentWord(message, lastWord, item) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.addItem(item) + forwardZapToEditting = TextFieldValue("") + } + + userSuggestionsMainMessage = null + it.reset() + } + + draftTag.newVersion() + } + + open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { + val wordToInsert = ":${item.code}:" + message.replaceCurrentWord(wordToInsert) + + emojiSuggestions?.reset() + + draftTag.newVersion() + } + + open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) { + val wordToInsert = item.link + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare(item.link) { + Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link) + } + } + + message.replaceCurrentWord(wordToInsert) + + emojiSuggestions?.reset() + + urlPreview = findUrlInMessage() + + draftTag.newVersion() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + uploadState?.mediaUploadTracker?.isUploading != true && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount != null) && + uploadState?.multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message.insertUrlAtCursor(newElement) + } + + override fun locationManager(): LocationState = Amethyst.instance.locationManager + + override fun locationFlow(): StateFlow { + if (location == null) { + location = locationManager().geohashStateFlow + } + + return location!! + } + + override fun onCleared() { + super.onCleared() + Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + } + + fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.updatePercentage(index, sliderValue) + } + + fun updateZapFromText() { + viewModelScope.launch(Dispatchers.IO) { + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.items.any { it.key == taggedUser }) { + forwardZapTo.addItem(taggedUser) + } + } + } + } + + fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount = newAmount + draftTag.newVersion() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + draftTag.newVersion() + } + + fun toggleExpirationDate() { + wantsExpirationDate = !wantsExpirationDate + if (wantsExpirationDate) { + expirationDate = TimeUtils.oneDayAhead() + } + draftTag.newVersion() + } +} From 995f8384df1cae40a9e006f27ddb42bbfded63e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 22:37:18 +0000 Subject: [PATCH 3/3] feat(nests): wire nest chat composer to NestNewMessageViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the copy approach started in 57ebded6: - NestEditFieldRow.kt: copy of EditFieldRow, drops the BackHandler (the activity owns back; draft auto-save runs on every keystroke so there is nothing to flush) and points every callback at NestNewMessageViewModel. - NestFileUploadDialog.kt: copy of ChannelFileUploadDialog, header shows the room host's avatar + room name from the loaded MeetingSpaceEvent. - NestChatPanel.kt: replaces the slim inline composer with NestEditFieldRow + NestNewMessageViewModel. Chat message list is still rendered from NestViewModel.chat — the new VM is scoped to *editing and sending* messages only, per the brief. - NestNewMessageViewModel.sendPostSync: switched from signAndSendPrivately(template, emptySet()) to signAndComputeBroadcast(template) so messages actually reach the user's default relays (matches the original slim composer's behaviour). --- .../loggedIn/nests/room/NestChatPanel.kt | 73 +++----- .../nests/room/send/NestEditFieldRow.kt | 167 ++++++++++++++++++ .../nests/room/send/NestFileUploadDialog.kt | 108 +++++++++++ .../room/send/NestNewMessageViewModel.kt | 6 +- 4 files changed, 302 insertions(+), 52 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestEditFieldRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/send/NestFileUploadDialog.kt 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) }