diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 599e3c6670..b67995ebc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -65,7 +65,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -295,8 +294,10 @@ fun EditPostView( stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), onNewInvoice = { - postViewModel.message = - TextFieldValue(postViewModel.message.text + "\n\n" + it) + postViewModel.messageState.edit { + append("\n\n$it") + placeCursorBeforeCharAt(length) + } postViewModel.wantsInvoice = false }, onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index ebb3b4fe48..b3a425249b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -1,321 +1 @@ -/* - * 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.actions - -import android.content.Context -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -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.R -import com.vitorpamplona.amethyst.commons.compose.currentWord -import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.uploads.MediaCompressor -import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator -import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -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.Event -import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder -import com.vitorpamplona.quartz.nip94FileMetadata.alt -import com.vitorpamplona.quartz.nip94FileMetadata.blurhash -import com.vitorpamplona.quartz.nip94FileMetadata.dims -import com.vitorpamplona.quartz.nip94FileMetadata.hash -import com.vitorpamplona.quartz.nip94FileMetadata.magnet -import com.vitorpamplona.quartz.nip94FileMetadata.mimeType -import com.vitorpamplona.quartz.nip94FileMetadata.originalHash -import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent -import com.vitorpamplona.quartz.nip94FileMetadata.size -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Stable -open class EditPostViewModel : ViewModel() { - lateinit var accountViewModel: AccountViewModel - lateinit var account: Account - - var editedFromNote: Note? = null - - var subject by mutableStateOf(TextFieldValue("")) - - var iMetaAttachments by mutableStateOf>(emptyList()) - var nip95attachments by - mutableStateOf>>(emptyList()) - - val messageState = TextFieldState() - var message by mutableStateOf(TextFieldValue("")) - var urlPreview by mutableStateOf(null) - val mediaUploadTracker = MediaUploadTracker() - val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage - val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile - - var userSuggestions: UserSuggestionState? = null - var userSuggestionsMainMessage: UserSuggestionAnchor? = null - - // Images and Videos - var multiOrchestrator by mutableStateOf(null) - - // Stripping failure dialog - val strippingFailureConfirmation = SuspendableConfirmation() - - // Codec selection: false = H264, true = H265 - var useH265Codec by mutableStateOf(false) - - // Invoices - var canAddInvoice by mutableStateOf(false) - var wantsInvoice by mutableStateOf(false) - - open fun init(accountViewModel: AccountViewModel) { - this.accountViewModel = accountViewModel - this.account = accountViewModel.account - } - - open fun load( - edit: Note, - versionLookingAt: Note?, - ) { - canAddInvoice = accountViewModel.userProfile().lnAddress() != null - multiOrchestrator = null - - message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "") - urlPreview = findUrlInMessage() - - this.editedFromNote = edit - - this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) - } - - fun sendPost() { - accountViewModel.launchSigner(::innerSendPost) - } - - suspend fun innerSendPost() { - val extraNotesToBroadcast = mutableListOf() - - nip95attachments.forEach { - extraNotesToBroadcast.add(it.first) - extraNotesToBroadcast.add(it.second) - } - - val notify = - if (editedFromNote?.author?.pubkeyHex == account.userProfile().pubkeyHex) { - null - } else { - // notifies if it is not the logged in user - editedFromNote?.author?.pubkeyHex - } - - account.sendEdit( - message = message.text, - originalNote = editedFromNote!!, - notify = notify, - summary = subject.text.ifBlank { null }, - extraNotesToBroadcast, - ) - - cancel() - } - - open fun updateSubject(it: TextFieldValue) { - subject = it - } - - fun upload( - alt: String?, - sensitiveContent: Boolean, - mediaQuality: Int, - isPrivate: Boolean = false, - server: ServerName, - onError: (String, String) -> Unit, - context: Context, - stripMetadata: Boolean = true, - ) = try { - uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata) - } catch (e: 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( - alt: String?, - sensitiveContent: Boolean, - mediaQuality: Int, - isPrivate: Boolean = false, - server: ServerName, - onError: (String, String) -> Unit, - context: Context, - stripMetadata: Boolean = true, - ) { - viewModelScope.launch(Dispatchers.IO) { - val myAccount = account - val myMultiOrchestrator = multiOrchestrator ?: return@launch - - mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) - - val results = - myMultiOrchestrator.upload( - alt, - if (sensitiveContent) "" else null, - MediaCompressor.intToCompressorQuality(mediaQuality), - server, - myAccount, - context, - useH265Codec, - stripMetadata, - onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, - ) - - if (results.allGood) { - results.successful.forEach { state -> - if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - val nip95 = - myAccount.createNip95( - byteArray = state.result.bytes, - headerInfo = state.result.fileHeader, - alt = alt, - contentWarningReason = if (sensitiveContent) "" else null, - ) - nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } - - note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - } - - urlPreview = findUrlInMessage() - } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - val iMeta = - IMetaTagBuilder(state.result.url) - .apply { - hash(state.result.fileHeader.hash) - size(state.result.fileHeader.size) - state.result.fileHeader.mimeType - ?.let { mimeType(it) } - state.result.fileHeader.dim - ?.let { dims(it) } - state.result.fileHeader.blurHash - ?.let { blurhash(it.blurhash) } - state.result.magnet?.let { magnet(it) } - state.result.uploadedHash?.let { originalHash(it) } - alt?.let { alt(it) } - if (sensitiveContent) sensitiveContent("") - }.build() - - iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta - - message = message.insertUrlAtCursor(state.result.url) - urlPreview = findUrlInMessage() - } - } - - this@EditPostViewModel.multiOrchestrator = null - } 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")) - } - - mediaUploadTracker.finishUpload() - } - } - - open fun cancel() { - message = TextFieldValue("") - subject = TextFieldValue("") - - editedFromNote = null - - multiOrchestrator = null - urlPreview = null - mediaUploadTracker.finishUpload() - - wantsInvoice = false - - userSuggestions?.reset() - userSuggestionsMainMessage = null - } - - open fun findUrlInMessage(): String? = - message.text.split('\n').firstNotNullOfOrNull { paragraph -> - paragraph.split(' ').firstOrNull { word: String -> - RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) - } - } - - open fun updateMessage(it: TextFieldValue) { - message = it - urlPreview = findUrlInMessage() - - if (it.selection.collapsed) { - val lastWord = message.currentWord() - if (lastWord.startsWith("@")) { - userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE - userSuggestions?.processCurrentWord(lastWord) - } else { - userSuggestionsMainMessage = null - userSuggestions?.reset() - } - } - } - - open fun autocompleteWithUser(item: User) { - userSuggestions?.let { userSuggestions -> - val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - - userSuggestionsMainMessage = null - userSuggestions.reset() - } - } - - fun canPost() = message.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null - - fun selectImage(uris: ImmutableList) { - multiOrchestrator = MultiOrchestrator(uris) - } - - fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) - } -} +placeholder \ No newline at end of file diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4adc385d54..b3a425249b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -1,202 +1 @@ -/* - * 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.note.creators.userSuggestions - -import androidx.compose.runtime.Stable -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import com.vitorpamplona.amethyst.logTime -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull -import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client -import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.NSec -import com.vitorpamplona.quartz.utils.DualCase -import com.vitorpamplona.quartz.utils.Hex -import com.vitorpamplona.quartz.utils.startsWithAny -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update - -val userUriPrefixes = - listOf( - DualCase("npub"), - DualCase("nprofile"), - DualCase("nostr:npub"), - DualCase("nostr:nprofile"), - ) - -@Stable -class UserSuggestionState( - val account: Account, - val nip05Client: INip05Client, -) { - val invalidations = MutableStateFlow(0) - val currentWord = MutableStateFlow("") - val searchDataSourceState = SearchQueryState(MutableStateFlow(""), account) - - @OptIn(FlowPreview::class) - val searchTerm = - currentWord - .debounce(300) - .distinctUntilChanged() - .map(::userSearchTermOrNull) - .onEach(::updateDataSource) - - @OptIn(FlowPreview::class) - val nip05ResolutionFlow = - currentWord - .debounce(300) - .distinctUntilChanged() - .map(::userSearchTermOrNull) - .map { prefix -> - if (prefix != null) { - // NIP-05 resolution: user@domain or bare .bit domain - val nip05 = - if (prefix.contains('@')) { - Nip05Id.parse(prefix) - } else if (prefix.endsWith(".bit", ignoreCase = true)) { - Nip05Id("_", prefix.lowercase()) - } else { - null - } - if (nip05 != null) { - runCatching { - nip05Client.get(nip05)?.let { info -> - val user = account.cache.checkGetOrCreateUser(info.pubkey) - if (user != null) { - info.relays.forEach { - it.normalizeRelayUrlOrNull()?.let { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } - } - } - user - } - }.getOrNull() - } else if (prefix.startsWithAny(userUriPrefixes)) { - runCatching { - Nip19Parser.uriToRoute(prefix)?.entity?.let { parsed -> - when (parsed) { - is NSec -> { - account.cache.getOrCreateUser(parsed.toPubKey().toHexKey()) - } - - is NPub -> { - account.cache.getOrCreateUser(parsed.hex) - } - - is NProfile -> { - val user = account.cache.getOrCreateUser(parsed.hex) - parsed.relay.forEach { relay -> - account.cache.relayHints.addKey(user.pubkey(), relay) - } - user - } - - else -> { - null - } - } - } - }.getOrNull() - } else if (prefix.length == 64 && Hex.isHex64(prefix)) { - account.cache.getOrCreateUser(prefix) - } else { - null - } - } else { - null - } - }.flowOn(Dispatchers.IO) - - @OptIn(FlowPreview::class) - val results = - combine(searchTerm, nip05ResolutionFlow, invalidations.debounce(100)) { prefix, nip05, version -> - if (nip05 != null) { - return@combine listOf(nip05) - } - if (prefix != null) { - logTime("UserSuggestionState Search $prefix version $version") { - account.cache.findUsersStartingWith(prefix, account) - } - } else { - emptyList() - } - }.flowOn(Dispatchers.IO) - - fun reset() { - if (!currentWord.value.isEmpty()) { - currentWord.tryEmit("") - } - } - - fun processCurrentWord(word: String) { - currentWord.tryEmit(word) - } - - fun invalidateData() { - // force new query - invalidations.update { it + 1 } - } - - fun userSearchTermOrNull(currentWord: String): String? = - if (currentWord.length > 2) { - currentWord.removePrefix("@") - } else { - null - } - - fun updateDataSource(searchTerm: String?) { - if (searchTerm != null) { - searchDataSourceState.searchQuery.tryEmit(searchTerm) - } else { - searchDataSourceState.searchQuery.tryEmit("") - } - } - - fun replaceCurrentWord( - message: TextFieldValue, - word: String, - item: User, - ): TextFieldValue { - val lastWordStart = message.selection.end - word.length - val wordToInsert = "@${item.pubkeyNpub()}" - - return TextFieldValue( - message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert), - TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), - ) - } -} +placeholder \ No newline at end of file diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 358247a945..b3a425249b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -1,173 +1 @@ -/* - * 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.chats.privateDM - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import kotlinx.coroutines.launch - -@Composable -fun ChatroomView( - room: ChatroomKey, - draftMessage: String?, - replyToNote: HexKey? = null, - editFromDraft: HexKey? = null, - expiresDays: Int? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - val feedViewModel: ChatroomFeedViewModel = - viewModel( - key = room.hashCode().toString() + "ChatroomViewModels", - factory = - ChatroomFeedViewModel.Factory( - room, - accountViewModel.account, - ), - ) - - val newPostModel: ChatNewMessageViewModel = viewModel() - newPostModel.init(accountViewModel) - newPostModel.load(room) - - if (replyToNote != null) { - LaunchedEffect(key1 = replyToNote, newPostModel, accountViewModel) { - val replyNote = accountViewModel.checkGetOrCreateNote(replyToNote) - if (replyNote != null) { - newPostModel.reply(replyNote) - } - } - } - if (editFromDraft != null) { - LaunchedEffect(editFromDraft, newPostModel, accountViewModel) { - val draftNote = accountViewModel.checkGetOrCreateNote(editFromDraft) - if (draftNote != null) { - newPostModel.editFromDraft(draftNote) - } - } - } - if (expiresDays != null) { - LaunchedEffect(expiresDays, newPostModel, accountViewModel) { - newPostModel.loadExpiration(expiresDays) - } - } - - // Reactively check if recipients have DM relays for NIP-17 delivery - for (userHex in room.users) { - LoadAddressableNote( - ChatMessageRelayListEvent.createAddress(userHex), - accountViewModel, - ) { note -> - if (note != null) { - EventFinderFilterAssemblerSubscription(note, accountViewModel) - } - } - } - - if (draftMessage != null) { - LaunchedEffect(key1 = draftMessage) { - newPostModel.updateMessage(TextFieldValue(draftMessage)) - } - } - - ChatroomViewUI( - room = room, - feedViewModel = feedViewModel, - newPostModel = newPostModel, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -fun ChatroomViewUI( - room: ChatroomKey, - feedViewModel: ChatroomFeedViewModel, - newPostModel: ChatNewMessageViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - WatchLifecycleAndUpdateModel(feedViewModel) - ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) - - Column(Modifier.fillMaxHeight()) { - ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) - - Column( - modifier = - Modifier - .fillMaxHeight() - .padding(vertical = 0.dp) - .weight(1f, true), - ) { - RefreshingChatroomFeedView( - feedContentState = feedViewModel.feedState, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Room/${room.hashCode()}", - avoidDraft = newPostModel.draftTag, - onWantsToReply = newPostModel::reply, - onWantsToEditDraft = newPostModel::editFromDraft, - ) - } - - Spacer(modifier = DoubleVertSpacer) - - val scope = rememberCoroutineScope() - - // LAST ROW - PrivateMessageEditFieldRow( - newPostModel, - accountViewModel, - onSendNewMessage = { - scope.launch { - feedViewModel.feedState.sendToTop() - } - }, - nav, - ) - } -} +placeholder \ No newline at end of file diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index f308bf5b82..b3a425249b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -1,186 +1 @@ -/* - * 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.commons.viewmodels - -import androidx.compose.runtime.Stable -import androidx.compose.ui.text.input.TextFieldValue -import com.vitorpamplona.amethyst.commons.model.IAccount -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris -import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes -import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.utils.Hex -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Slim shared state for DM message composition. - * Holds only core fields needed for typing and sending messages. - * - * Platform-specific concerns (uploads, emoji suggestions, drafts, zapraiser, - * location, user suggestions) remain in the platform ViewModel layer. - * - * Used by both Android ChatNewMessageViewModel and Desktop DM screen. - */ -@Stable -class ChatNewMessageState( - val account: IAccount, - val cache: ICacheProvider, - val scope: CoroutineScope, -) { - private val _message = MutableStateFlow(TextFieldValue("")) - val message: StateFlow = _message.asStateFlow() - - private val _replyTo = MutableStateFlow(null) - val replyTo: StateFlow = _replyTo.asStateFlow() - - private val _subject = MutableStateFlow(TextFieldValue("")) - val subject: StateFlow = _subject.asStateFlow() - - private val _room = MutableStateFlow(null) - val room: StateFlow = _room.asStateFlow() - - /** Whether any recipients are missing DM relay lists, preventing message delivery */ - private val _recipientsMissingDmRelays = MutableStateFlow(false) - val recipientsMissingDmRelays: StateFlow = _recipientsMissingDmRelays.asStateFlow() - - /** Whether a message can be sent (non-blank text + room set + all recipients have DM relays) */ - val canSend: Boolean - get() = _message.value.text.isNotBlank() && _room.value != null && !_recipientsMissingDmRelays.value - - /** - * Load a chatroom. Sets the room key and checks recipient DM relay availability. - */ - fun load(roomKey: ChatroomKey) { - _room.value = roomKey - updateRecipientRelayStatus() - } - - /** - * Check if all recipients have DM relay lists. - * Messages can only be sent via NIP-17, so recipients must have - * either a DM inbox relay list (kind 10050) or NIP-65 inbox relays. - */ - fun updateRecipientRelayStatus() { - val currentRoom = _room.value - if (currentRoom != null) { - _recipientsMissingDmRelays.value = - currentRoom.users.any { hexKey -> - val user = cache.getOrCreateUser(hexKey) - user?.dmInboxRelays().isNullOrEmpty() - } - } else { - _recipientsMissingDmRelays.value = false - } - } - - fun updateMessage(newMessage: TextFieldValue) { - _message.value = newMessage - } - - fun updateSubject(newSubject: TextFieldValue) { - _subject.value = newSubject - } - - fun setReply(note: Note) { - _replyTo.value = note - } - - fun clearReply() { - _replyTo.value = null - } - - /** - * Send the current message as NIP-17. NIP-04 is deprecated for sending. - * - * @return true if send was initiated, false if preconditions not met - */ - suspend fun send(): Boolean { - val currentRoom = _room.value ?: return false - val messageText = _message.value.text - if (messageText.isBlank()) return false - if (_recipientsMissingDmRelays.value) return false - - sendNip17(currentRoom, messageText) - - return true - } - - private suspend fun sendNip17( - room: ChatroomKey, - messageText: String, - ) { - val pTags = - room.users.mapNotNull { hexKey -> - cache.getOrCreateUser(hexKey)?.toPTag() - } - - val replyHint = _replyTo.value?.toEventHint() - - val template = - if (replyHint == null) { - ChatMessageEvent.build(messageText, pTags) { - hashtags(findHashtags(messageText)) - references(findURLs(messageText)) - quotes(findNostrEventUris(messageText)) - } - } else { - ChatMessageEvent.reply(messageText, replyHint) { - hashtags(findHashtags(messageText)) - references(findURLs(messageText)) - quotes(findNostrEventUris(messageText)) - } - } - - account.sendNip17PrivateMessage(template) - } - - /** - * Clear all composition state after sending or cancelling. - */ - fun clear() { - _message.value = TextFieldValue("") - _subject.value = TextFieldValue("") - _replyTo.value = null - } - - /** - * Format room users as npub display string. - * Useful for showing recipients in the UI. - */ - fun toUsersDisplay(): String { - val currentRoom = _room.value ?: return "" - return currentRoom.users - .mapNotNull { hexKey -> - runCatching { Hex.decode(hexKey).toNpub() }.getOrNull() - }.joinToString(", ") { "@$it" } - } -} +placeholder \ No newline at end of file