refactor: build the draft note from the tag inside DraftTagState

Instead of capturing the AddressableNote returned by each save (and on
load) and re-assigning it, DraftTagState now builds the note from the
current tag via an injected (tag -> AddressableNote) builder and rebuilds
it whenever the tag changes. Because that note is the live cached object
for the address, its event tracks the draft automatically as it is saved
or removed, so:

- the note is never null inside the state (lateinit, wired by start());
- createAndSendDraftIgnoreErrors no longer needs to return the note, and
  load no longer needs to capture it — set(oldTag) rebuilds it;
- the writer's existence check becomes "is there a real, non-deleted
  draft event in the note" (DraftWrapEvent.isDeleted()), which also stops
  a second blank-delete from re-signing an already-emptied draft.

ViewModels just wire draftTag.start(account::getOrCreateDraftNote) in
init() and reference draftTag.note; the per-VM field and held() are gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
This commit is contained in:
Claude
2026-06-26 15:38:55 +00:00
parent a242411df6
commit b5706564d9
10 changed files with 50 additions and 51 deletions
@@ -2046,29 +2046,31 @@ class Account(
}
/**
* Returns the local [AddressableNote] that now holds the draft so the caller (the
* composer ViewModel) can keep a strong reference to it. [LocalCache.addressables] only
* keeps weak references, so without an owner holding the note it can be garbage collected
* and a later deletion would not find it locally (leaving an orphan on the relays).
* The live [AddressableNote] backing a draft tag for this account. It is the same cached
* note that draft events are consumed into, so its `event` tracks the draft over time. The
* composer holds onto it (via DraftTagState) so [LocalCache]'s weak reference can't collect
* it before a deletion needs it, which would otherwise orphan the draft on the relays.
*/
fun getOrCreateDraftNote(draftTag: String): AddressableNote = cache.getOrCreateAddressableNote(DraftWrapEvent.createAddress(signer.pubKey, draftTag))
suspend fun createAndSendDraftIgnoreErrors(
draftTag: String,
template: EventTemplate<out Event>,
broadcast: Set<Event> = emptySet(),
): AddressableNote? =
) {
try {
createAndSendDraftInner(draftTag, template, broadcast)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
suspend fun createAndSendDraftInner(
draftTag: String,
template: EventTemplate<out Event>,
broadcast: Set<Event> = emptySet(),
): AddressableNote? {
if (!isWriteable()) return null
) {
if (!isWriteable()) return
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
@@ -2085,11 +2087,9 @@ class Account(
client.publish(it, relayList.toSet())
}
}
return cache.getOrCreateAddressableNote(draftEvent.address())
}
suspend fun deleteDraftIgnoreErrors(draftNote: AddressableNote?) {
suspend fun deleteDraftIgnoreErrors(draftNote: AddressableNote) {
try {
deleteDraftInner(draftNote)
} catch (e: Exception) {
@@ -2097,13 +2097,15 @@ class Account(
}
}
suspend fun deleteDraftInner(draftNote: AddressableNote?) {
suspend fun deleteDraftInner(draftNote: AddressableNote) {
if (!isWriteable()) return
// Nothing to delete means nothing to sign. The caller passes the note it has been
// holding (so it can't be garbage collected before we get here); a null note or one
// without an event means no draft was ever created, so we avoid prompting the signer.
if (draftNote?.event == null) return
// Only a real, still-present draft needs a deletion signed. The note is always non-null
// (it's the live cache note for the tag), but its event is null when no draft was ever
// saved (e.g. auto-drafts disabled) and already empty once it has been deleted — in both
// cases there is nothing to delete, so we avoid prompting the signer.
val draftEvent = draftNote.event as? DraftWrapEvent
if (draftEvent == null || draftEvent.isDeleted()) return
val draftTag = draftNote.dTag()
val extraRelays = draftNote.relays
@@ -37,14 +37,17 @@ class DraftTagState {
var current: String by mutableStateOf(newTag())
var usedDraftTags by mutableStateOf(setOf(current))
private var noteBuilder: ((tag: String) -> AddressableNote)? = null
/**
* Strong reference to the AddressableNote backing the current draft tag. LocalCache only
* keeps weak references to addressables, so without an owner holding the note it can be
* garbage-collected and a later deletion would not find it locally, orphaning the draft on
* the relays. Its lifecycle is tied to the tag: [held] when a draft is saved or an existing
* draft is loaded, and dropped by [rotate] when we move on to a fresh draft.
* Strong reference to the AddressableNote backing the [current] draft tag, kept alive so
* LocalCache's weak reference can't garbage-collect it before a deletion needs it (which
* would orphan the draft on the relays). It is the live cached note for the tag, so its
* `event` reflects the draft automatically as it is saved or removed no need to re-assign
* it after each save. Rebuilt whenever the tag changes ([set]/[rotate]); valid once [start]
* wires the builder, which happens when the composer is initialized.
*/
var note: AddressableNote? = null
lateinit var note: AddressableNote
private set
private val _versions = MutableStateFlow(0)
@@ -55,20 +58,21 @@ class DraftTagState {
@OptIn(ExperimentalUuidApi::class)
fun newTag() = Uuid.random().toString()
/** Wires the tag -> note builder and builds the note for the current tag. */
fun start(builder: (tag: String) -> AddressableNote) {
noteBuilder = builder
note = builder(current)
}
fun rotate() {
set(newTag())
note = null
_versions.update { 0 }
}
fun set(existingTag: String) {
current = existingTag
usedDraftTags += existingTag
}
/** Keeps a strong reference to the note that backs the current draft. */
fun held(note: AddressableNote?) {
this.note = note
noteBuilder?.let { note = it(existingTag) }
}
fun newVersion() {
@@ -39,7 +39,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -238,6 +237,7 @@ open class CommentPostViewModel :
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -257,7 +257,6 @@ open class CommentPostViewModel :
val noteAuthor = draft.author
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -517,7 +516,7 @@ open class CommentPostViewModel :
}
val template = createTemplate() ?: return
draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
}
@@ -37,7 +37,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -258,6 +257,7 @@ class ChatNewMessageViewModel :
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -323,7 +323,6 @@ class ChatNewMessageViewModel :
val noteAuthor = draft.author
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -619,7 +618,7 @@ class ChatNewMessageViewModel :
}
if (draftTag != null) {
this.draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
}
@@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -185,6 +184,7 @@ open class ChannelNewMessageViewModel :
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -225,7 +225,6 @@ open class ChannelNewMessageViewModel :
val noteAuthor = draft.author
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -325,7 +324,7 @@ open class ChannelNewMessageViewModel :
}
val template = createTemplate() ?: return
draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
}
@@ -39,7 +39,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -215,6 +214,7 @@ class LongFormPostViewModel :
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -233,7 +233,6 @@ class LongFormPostViewModel :
val noteAuthor = draft?.author
if (draft != null && noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -353,7 +352,7 @@ class LongFormPostViewModel :
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.note)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val template = createTemplate() ?: return
draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, emptySet()))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, emptySet())
}
}
@@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -199,6 +198,7 @@ open class NewProductViewModel :
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -214,7 +214,6 @@ open class NewProductViewModel :
val noteAuthor = draft.author
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -331,7 +330,7 @@ open class NewProductViewModel :
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.note)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val template = createTemplate() ?: return
draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template)
}
}
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -458,6 +457,7 @@ open class ShortNotePostViewModel :
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -479,7 +479,6 @@ open class ShortNotePostViewModel :
val noteAuthor = draft?.author
if (draft != null && noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -978,7 +977,7 @@ open class ShortNotePostViewModel :
}
val template = createTemplate() ?: return
draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
}
@@ -190,6 +190,7 @@ open class NestNewMessageViewModel :
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -230,7 +231,6 @@ open class NestNewMessageViewModel :
val noteAuthor = draft.author
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -328,7 +328,7 @@ open class NestNewMessageViewModel :
}
val template = createTemplate() ?: return
draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
}
}
@@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -209,6 +208,7 @@ class NewPublicMessageViewModel :
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
draftTag.start(account::getOrCreateDraftNote)
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
@@ -258,7 +258,6 @@ class NewPublicMessageViewModel :
val noteAuthor = draft.author
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
draftTag.held(draft as? AddressableNote)
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -362,7 +361,7 @@ class NewPublicMessageViewModel :
}
val template = createTemplate()
draftTag.held(accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast))
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast)
}
}