wip(nests): start copying ChannelNewMessageViewModel into nest package

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.
This commit is contained in:
Claude
2026-04-27 22:31:46 +00:00
parent 46cfab4fa1
commit 57ebded68b
2 changed files with 647 additions and 8 deletions
@@ -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)
@@ -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<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()
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<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()
}
}