Merge pull request #3383 from vitorpamplona/claude/amethyst-draft-signing-bug-9cx0zx

Fix draft deletion by holding strong reference to draft notes
This commit is contained in:
Vitor Pamplona
2026-06-26 14:52:24 -04:00
committed by GitHub
9 changed files with 109 additions and 31 deletions
@@ -2045,6 +2045,14 @@ class Account(
extraNotesToBroadcast.forEach { client.publish(it, 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>,
@@ -2081,18 +2089,25 @@ class Account(
}
}
suspend fun deleteDraftIgnoreErrors(draftTag: String) {
suspend fun deleteDraftIgnoreErrors(draftNote: AddressableNote?) {
try {
deleteDraftInner(draftTag)
deleteDraftInner(draftNote)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
suspend fun deleteDraftInner(draftTag: String) {
suspend fun deleteDraftInner(draftNote: AddressableNote?) {
if (!isWriteable()) return
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
// Only a real, still-present draft needs a deletion signed. The note's 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
val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer)
val deletionEvent = signer.sign(DeletionEvent.build(listOf(deletedDraft)))
@@ -39,6 +39,7 @@ 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
@@ -136,11 +137,17 @@ open class CommentPostViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
init {
viewModelScope.launch(Dispatchers.IO) {
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
sendDraftSync()
}
}
@@ -261,6 +268,7 @@ open class CommentPostViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -489,7 +497,7 @@ open class CommentPostViewModel :
}
}
val version = draftTag.current
val draftToDelete = draftNote
val anonymous = wantsAnonymousPost
cancel()
@@ -500,14 +508,14 @@ open class CommentPostViewModel :
}
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
attachments.add(it.first)
@@ -37,6 +37,7 @@ 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
@@ -126,6 +127,11 @@ class ChatNewMessageViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
@@ -134,6 +140,7 @@ class ChatNewMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
accountViewModel.launchSigner {
sendDraftSync()
}
@@ -327,6 +334,7 @@ class ChatNewMessageViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -418,17 +426,17 @@ class ChatNewMessageViewModel :
}
suspend fun sendPostSync() {
val version = draftTag.current
val draftToDelete = draftNote
innerSendPost(null)
cancel()
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
innerSendPost(draftTag.current)
}
@@ -43,6 +43,7 @@ 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
@@ -112,11 +113,17 @@ open class ChannelNewMessageViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
init {
viewModelScope.launch(Dispatchers.IO) {
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
accountViewModel.launchSigner {
sendDraftSync()
}
@@ -229,6 +236,7 @@ open class ChannelNewMessageViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -301,20 +309,20 @@ open class ChannelNewMessageViewModel :
val template = createTemplate() ?: return
val channelRelays = channel?.relays() ?: emptySet()
val version = draftTag.current
val draftToDelete = draftNote
cancel()
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) {
channelRelays.toList()
}
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
@@ -39,6 +39,7 @@ 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
@@ -128,6 +129,11 @@ class LongFormPostViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
@@ -136,6 +142,7 @@ class LongFormPostViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
accountViewModel.launchSigner {
sendDraftSync()
}
@@ -237,6 +244,7 @@ class LongFormPostViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -324,7 +332,7 @@ class LongFormPostViewModel :
suspend fun sendPostSync() {
val template = createTemplate() ?: return
val version = draftTag.current
val draftToDelete = draftNote
cancel()
if (accountViewModel.settings.useTrackedBroadcasts()) {
@@ -342,13 +350,13 @@ class LongFormPostViewModel :
}
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank() && title.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, emptySet())
@@ -38,6 +38,7 @@ 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
@@ -114,6 +115,11 @@ open class NewProductViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
@@ -122,6 +128,7 @@ open class NewProductViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
accountViewModel.launchSigner {
sendDraftSync()
}
@@ -218,6 +225,7 @@ open class NewProductViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -315,18 +323,18 @@ open class NewProductViewModel :
suspend fun sendPostSync() {
val template = createTemplate() ?: return
val version = draftTag.current
val draftToDelete = draftNote
cancel()
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template, relayList = { relayList })
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template)
@@ -41,6 +41,7 @@ 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
@@ -183,6 +184,11 @@ open class ShortNotePostViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
@@ -191,6 +197,7 @@ open class ShortNotePostViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
accountViewModel.launchSigner {
sendDraftSync()
}
@@ -483,6 +490,7 @@ open class ShortNotePostViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -889,7 +897,7 @@ open class ShortNotePostViewModel :
}
}
val version = draftTag.current
val draftToDelete = draftNote
val anonymous = wantsAnonymousPost
val scheduledFor = scheduledForSec
val privately = wantsPrivateNote
@@ -903,7 +911,7 @@ open class ShortNotePostViewModel :
@Suppress("UNCHECKED_CAST")
accountViewModel.account.sendPrivateNote(template as EventTemplate<TextNoteEvent>)
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
return
}
@@ -935,7 +943,7 @@ open class ShortNotePostViewModel :
),
)
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
return
}
@@ -961,13 +969,13 @@ open class ShortNotePostViewModel :
}
accountViewModel.launchSigner {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
@@ -118,11 +118,17 @@ open class NestNewMessageViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
init {
viewModelScope.launch(Dispatchers.IO) {
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
accountViewModel.launchSigner {
sendDraftSync()
}
@@ -235,6 +241,7 @@ open class NestNewMessageViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -303,7 +310,7 @@ open class NestNewMessageViewModel :
suspend fun sendPostSync() {
val template = createTemplate() ?: return
val version = draftTag.current
val draftToDelete = draftNote
cancel()
// Broadcast to the user's default relays — the nest has no
@@ -312,13 +319,13 @@ open class NestNewMessageViewModel :
// `account.signAndComputeBroadcast(...)`.
accountViewModel.account.signAndComputeBroadcast(template)
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
@@ -38,6 +38,7 @@ 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
@@ -129,6 +130,11 @@ class NewPublicMessageViewModel :
IExpiration {
val draftTag = DraftTagState()
// Strong reference to the live cache note for the current draft tag (derived from the
// versions flow below), so LocalCache cannot weakly collect it before a deletion needs it.
var draftNote: AddressableNote? = null
private set
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
@@ -137,6 +143,7 @@ class NewPublicMessageViewModel :
draftTag.versions.collectLatest {
// don't save the first
if (it > 0) {
draftNote = account.getOrCreateDraftNote(draftTag.current)
accountViewModel.launchSigner {
sendDraftSync()
}
@@ -262,6 +269,7 @@ class NewPublicMessageViewModel :
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag.set(oldTag)
draftNote = account.getOrCreateDraftNote(oldTag)
}
loadFromDraft(innerNote)
}
@@ -340,18 +348,18 @@ class NewPublicMessageViewModel :
}
}
val version = draftTag.current
val draftToDelete = draftNote
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
}
suspend fun sendDraftSync() {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
accountViewModel.account.deleteDraftIgnoreErrors(draftNote)
} else if (accountViewModel.settings.automaticallyCreateDrafts()) {
val broadcast = mutableSetOf<Event>()
nip95attachments.forEach {