From 57ebded68bea7b5a0fdf5912a53ecc835a7853c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 22:31:46 +0000 Subject: [PATCH] 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() + } +}