This commit is contained in:
Vitor Pamplona
2026-04-27 19:25:21 -04:00
4 changed files with 953 additions and 118 deletions
@@ -31,71 +31,53 @@ 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 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.nests.room.send.NestEditFieldRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.send.NestNewMessageViewModel
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
import androidx.lifecycle.viewmodel.compose.viewModel as composeViewModel
/**
* 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.
* 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.
*
* 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.
* 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(
@@ -109,17 +91,13 @@ internal fun ColumnScope.NestChatPanel(
val scope = rememberCoroutineScope()
val nav = remember(context, scope) { BouncingIntentNav(context, scope) }
val roomATag =
remember(event) {
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
)
}
val routeForLastRead = remember(event) { "NestChat/${event.address().toValue()}" }
val nestScreenModel: NestNewMessageViewModel =
composeViewModel(key = "Nest/${event.address().toValue()}")
nestScreenModel.init(accountViewModel)
nestScreenModel.load(event)
Column(modifier = modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
if (messages.isEmpty()) {
@@ -135,15 +113,18 @@ internal fun ColumnScope.NestChatPanel(
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = nestScreenModel::reply,
)
}
}
Spacer(Modifier.height(8.dp))
NestChatComposer(
roomATag = roomATag,
NestEditFieldRow(
nestScreenModel = nestScreenModel,
accountViewModel = accountViewModel,
onSendNewMessage = {},
nav = nav,
)
}
}
@@ -154,6 +135,7 @@ private fun NestChatMessageList(
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: BouncingIntentNav,
onWantsToReply: (Note) -> Unit,
) {
val listState = rememberLazyListState()
@@ -192,79 +174,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 = {}
@@ -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),
)
}
}
@@ -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,
)
}
@@ -0,0 +1,646 @@
/*
* 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<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
// Stripping failure dialog
val strippingFailureConfirmation = SuspendableConfirmation()
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
val message = TextFieldState()
var urlPreview by mutableStateOf<String?>(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<User>>(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<LocationState.LocationResult>? = null
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(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()
// 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)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
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<SelectedMedia>) {
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<out Event>? {
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<LiveActivitiesChatMessageEvent>()
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<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
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<LocationState.LocationResult> {
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()
}
}