mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/sharp-keller-7itny0
This commit is contained in:
@@ -1428,6 +1428,9 @@ class Account(
|
||||
// wraps still register with the delivery-ticks tracker at publish time.
|
||||
// Null for restart-restored jobs, whose rumor id wasn't persisted.
|
||||
displayedNoteId: HexKey? = null,
|
||||
// Runs once the mined wraps are handed to the relays (e.g. to drop the
|
||||
// composer's draft). Not persisted: a restart-restored job skips it.
|
||||
onPublished: suspend () -> Unit = {},
|
||||
): Boolean {
|
||||
val queue = powQueue() ?: return false
|
||||
if (seals.isEmpty()) return true
|
||||
@@ -1462,7 +1465,10 @@ class Account(
|
||||
}
|
||||
seals.map { NIP17Factory().wrapSeal(it, expirationDelta, templateConversion = mineWrap) }
|
||||
},
|
||||
publish = { wraps -> broadcastPrivately(wraps, displayedNoteId) },
|
||||
publish = { wraps ->
|
||||
broadcastPrivately(wraps, displayedNoteId)
|
||||
onPublished()
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -2944,13 +2950,24 @@ class Account(
|
||||
broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer))
|
||||
}
|
||||
|
||||
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
|
||||
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) = sendNip17PrivateMessage(template) {}
|
||||
|
||||
/**
|
||||
* [onSent] runs once the wraps are handed to the relays — right away when
|
||||
* the message is not mined, after the nonce search when it is — so the
|
||||
* composer can keep its draft for as long as the message only exists in
|
||||
* the mining queue.
|
||||
*/
|
||||
suspend fun sendNip17PrivateMessage(
|
||||
template: EventTemplate<ChatMessageEvent>,
|
||||
onSent: suspend () -> Unit,
|
||||
) {
|
||||
val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND)
|
||||
if (powDifficulty != null) {
|
||||
// See sendNip17EncryptedFile: sign inline, queue only wrap mining.
|
||||
val senderMessage = signer.sign(template)
|
||||
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
|
||||
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id)) {
|
||||
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id, onPublished = onSent)) {
|
||||
// The wraps publish only after mining, but the user has already
|
||||
// replied — advance the read marker now.
|
||||
markDmRoomAsRead(senderMessage)
|
||||
@@ -2959,6 +2976,7 @@ class Account(
|
||||
}
|
||||
|
||||
broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer))
|
||||
onSent()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2970,10 +2988,13 @@ class Account(
|
||||
*
|
||||
* [powOverrideDifficulty] is the composer chip's per-post override:
|
||||
* null follows the account's gift-wrap setting, 0 disables mining.
|
||||
* [onSent] runs once the wraps are handed to the relays (after mining,
|
||||
* when mined).
|
||||
*/
|
||||
suspend fun sendPrivateNote(
|
||||
template: EventTemplate<TextNoteEvent>,
|
||||
powOverrideDifficulty: Int? = null,
|
||||
onSent: suspend () -> Unit = {},
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -2983,10 +3004,11 @@ class Account(
|
||||
val senderNote = signer.sign(template)
|
||||
val recipients = senderNote.taggedUserIds().plus(signer.pubKey).toSet()
|
||||
val seals = NIP17Factory().createSeals(senderNote, recipients, signer)
|
||||
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id)) return
|
||||
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id, onPublished = onSent)) return
|
||||
}
|
||||
|
||||
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
|
||||
onSent()
|
||||
}
|
||||
|
||||
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
||||
|
||||
+26
-6
@@ -42,6 +42,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -94,6 +95,18 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
|
||||
protected open val refreshMs: Long? = null
|
||||
|
||||
/**
|
||||
* How long to keep the service (and whatever it holds up) alive after [isActive] turns
|
||||
* false, before stopping. Lets work that was just handed off asynchronously — e.g. a
|
||||
* broadcast still leaving through the relay pool — finish before the process loses its
|
||||
* foreground protection and gets frozen. A new active emission during the grace keeps
|
||||
* the service running. Asked each time the work drains, so it can depend on how it did.
|
||||
*/
|
||||
protected open fun stopGraceMs(): Long = 0L
|
||||
|
||||
/** The card shown during [stopGraceMs]; null keeps the last one. */
|
||||
protected open fun renderDraining(): Content? = null
|
||||
|
||||
protected abstract fun state(): StateFlow<T>
|
||||
|
||||
/** Keep the service (and notification) alive while this is true; stop once it goes false. */
|
||||
@@ -222,9 +235,15 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
onStarted()
|
||||
watchJob =
|
||||
scope.launch {
|
||||
state().collect { value ->
|
||||
state().collectLatest { value ->
|
||||
onEmission(value)
|
||||
if (!isActive(value)) {
|
||||
// collectLatest: a new active emission cancels this pending stop.
|
||||
val grace = stopGraceMs()
|
||||
if (grace > 0) {
|
||||
renderDraining()?.let { notify(buildNotification(it)) }
|
||||
delay(grace)
|
||||
}
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
@@ -246,7 +265,7 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
|
||||
private fun startForegroundCompat(value: T) {
|
||||
ensureChannel()
|
||||
val notification = buildNotification(value)
|
||||
val notification = buildNotification(render(value))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(notificationId, notification, fgsType)
|
||||
} else {
|
||||
@@ -254,18 +273,19 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotification(value: T) {
|
||||
private fun updateNotification(value: T) = notify(buildNotification(render(value)))
|
||||
|
||||
private fun notify(notification: Notification) {
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
try {
|
||||
manager.notify(notificationId, buildNotification(value))
|
||||
manager.notify(notificationId, notification)
|
||||
} catch (_: SecurityException) {
|
||||
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(value: T): Notification {
|
||||
val content = render(value)
|
||||
private fun buildNotification(content: Content): Notification {
|
||||
val style =
|
||||
when (val bar = content.bar) {
|
||||
is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
|
||||
|
||||
+67
@@ -22,15 +22,20 @@ package com.vitorpamplona.amethyst.service.pow
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.PowerManager
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPhase
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
|
||||
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -44,6 +49,17 @@ import kotlinx.coroutines.launch
|
||||
* unmined resumes on the next app launch. Started on every enqueue (the app
|
||||
* is necessarily in the foreground then), stops itself when the queue drains.
|
||||
*
|
||||
* Staying schedulable is not enough on its own once the user also locks the
|
||||
* screen or leaves the app, so while it runs the service also:
|
||||
* - holds a partial wake lock, or the CPU suspends with the screen off and the
|
||||
* nonce search stalls until the phone is unlocked;
|
||||
* - keeps the relay pool (and Tor) up. The UI stops holding them ~30 s after
|
||||
* the app is backgrounded, so a post mined after that would only reach the
|
||||
* in-memory outbox — while its checkpoint and draft were already deleted —
|
||||
* and die with the process;
|
||||
* - lingers [PUBLISH_GRACE_MS] after the queue drains, so the event that was
|
||||
* just handed to the pool actually leaves before the process is frozen.
|
||||
*
|
||||
* The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
|
||||
* service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
|
||||
* to that card.
|
||||
@@ -68,17 +84,60 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
|
||||
private var sessionTotal = 0
|
||||
private var lastQueueSize = 0
|
||||
|
||||
// Whether the last non-empty queue still had a job publishing: a queue that drains by
|
||||
// publishing needs the grace for the event to leave; one drained by cancel does not.
|
||||
private var drainedByPublishing = false
|
||||
|
||||
override fun stopGraceMs() = if (drainedByPublishing) PUBLISH_GRACE_MS else 0L
|
||||
|
||||
override fun renderDraining() = Content(stringRes(this, R.string.pow_notification_sending), null, Bar.Indeterminate)
|
||||
|
||||
// Benchmarked once per service run (~250 ms, cached by the estimator).
|
||||
@Volatile
|
||||
private var hashRate: Double? = null
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
running = true
|
||||
|
||||
wakeLock =
|
||||
runCatching {
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:pow-mining")
|
||||
.apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(WAKE_LOCK_TIMEOUT_MS)
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Could not acquire the mining wake lock", it) }
|
||||
.getOrNull()
|
||||
|
||||
// Re-arm the timeout while the service lives: before Android 14 there is no FGS budget
|
||||
// and a hard nonce can mine for hours, but a lock with no timeout could outlive a
|
||||
// service that died without onDestroy.
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(WAKE_LOCK_TIMEOUT_MS / 2)
|
||||
wakeLock?.let { if (it.isHeld) it.acquire(WAKE_LOCK_TIMEOUT_MS) }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors what the resumed UI collects (AccountScreen's ManageRelayServices +
|
||||
// ManageWebOkHttp): subscribed, they keep the relay pool connected and, through
|
||||
// the connector's combine and the proxy-port provider, Tor up.
|
||||
val app = Amethyst.instance
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch { app.relayProxyClientConnector.relayServices.collect {} }
|
||||
launch { app.okHttpClients.defaultHttpClient.collect {} }
|
||||
launch { app.okHttpClients.defaultHttpClientWithoutProxy.collect {} }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
running = false
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
wakeLock = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -97,6 +156,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
|
||||
}
|
||||
|
||||
override fun onEmission(value: ImmutableList<PoWJobState>) {
|
||||
if (value.isNotEmpty()) drainedByPublishing = value.any { it.phase == PoWJobPhase.PUBLISHING }
|
||||
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
|
||||
lastQueueSize = value.size
|
||||
}
|
||||
@@ -155,6 +215,13 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
|
||||
|
||||
private const val PROGRESS_REFRESH_MS = 30_000L
|
||||
|
||||
// Time for a just-mined post to connect, send and get its OK before the service
|
||||
// lets go of the relays and the process becomes freezable.
|
||||
private const val PUBLISH_GRACE_MS = 20_000L
|
||||
|
||||
// Safety net only: onDestroy releases it, and the service re-arms it while alive.
|
||||
private const val WAKE_LOCK_TIMEOUT_MS = 10 * 60 * 1000L
|
||||
|
||||
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
+18
-4
@@ -160,8 +160,12 @@ open class CommentPostViewModel :
|
||||
draftTag.versions.collectLatest {
|
||||
// don't save the first
|
||||
if (it > 0) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
sendDraftSync()
|
||||
val tag = draftTag.current
|
||||
draftNote = account.getOrCreateDraftNote(tag)
|
||||
// Post rotates the tag and then clears the composer. A save still queued from
|
||||
// before that would see the empty text and delete the draft Post just saved
|
||||
// for the post that is still mining, so it's skipped once the tag has moved on.
|
||||
if (draftTag.current == tag) sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,10 +588,20 @@ open class CommentPostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
val draftToDelete = draftNote
|
||||
val anonymous = wantsAnonymousPost
|
||||
// captured before cancel() resets the chip
|
||||
val chosenPow = powOverride
|
||||
|
||||
// A mined post leaves the phone minutes after this returns — much later if the
|
||||
// app is backgrounded and frozen — and the draft is its only user-visible copy
|
||||
// until then. The auto-save is debounced and cancel() below drops the pending
|
||||
// save, so the last second of typing would never reach it: flush it now.
|
||||
if (accountViewModel.account.powDifficultyFor(template.kind, chosenPow) != null) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
sendDraftSync()
|
||||
}
|
||||
|
||||
val draftToDelete = draftNote
|
||||
val anonymous = wantsAnonymousPost
|
||||
onUiThread { cancel() }
|
||||
|
||||
// Draft deletion lives INSIDE each publish continuation: when the post
|
||||
|
||||
+27
-9
@@ -92,6 +92,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -139,9 +140,13 @@ class ChatNewMessageViewModel :
|
||||
draftTag.versions.collectLatest {
|
||||
// don't save the first
|
||||
if (it > 0) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
val tag = draftTag.current
|
||||
draftNote = account.getOrCreateDraftNote(tag)
|
||||
accountViewModel.launchSigner {
|
||||
sendDraftSync()
|
||||
// Post rotates the tag and then clears the composer. A save still queued from
|
||||
// before that would see the empty text and delete the draft Post just saved
|
||||
// for the post that is still mining, so it's skipped once the tag has moved on.
|
||||
if (draftTag.current == tag) sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,12 +432,22 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
val draftToDelete = draftNote
|
||||
innerSendPost(null)
|
||||
onUiThread { cancel() }
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
|
||||
// With PoW on, the gift wraps are mined in the background and leave the phone
|
||||
// minutes later; until then the draft is the only copy the user can see. The
|
||||
// auto-save is debounced and cancel() drops the pending save, so flush it now
|
||||
// and delete it only once the wraps are actually sent.
|
||||
if (accountViewModel.account.powDifficultyFor(GiftWrapEvent.KIND) != null) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
sendDraftSync()
|
||||
}
|
||||
|
||||
val draftToDelete = draftNote
|
||||
val account = accountViewModel.account
|
||||
innerSendPost(null) {
|
||||
// off the caller: signing the deletion must not hold up clearing the composer.
|
||||
account.scope.launch(Dispatchers.IO) { account.deleteDraftIgnoreErrors(draftToDelete) }
|
||||
}
|
||||
onUiThread { cancel() }
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
@@ -575,7 +590,10 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerSendPost(draftTag: String?) {
|
||||
private suspend fun innerSendPost(
|
||||
draftTag: String?,
|
||||
onSent: suspend () -> Unit = {},
|
||||
) {
|
||||
val room = room.value ?: return
|
||||
|
||||
val messageText = message.text.toString()
|
||||
@@ -628,7 +646,7 @@ class ChatNewMessageViewModel :
|
||||
if (draftTag != null) {
|
||||
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
|
||||
} else {
|
||||
accountViewModel.account.sendNip17PrivateMessage(template)
|
||||
accountViewModel.account.sendNip17PrivateMessage(template, onSent)
|
||||
}
|
||||
|
||||
if (draftTag == null) {
|
||||
|
||||
+16
-2
@@ -152,9 +152,13 @@ open class ChannelNewMessageViewModel :
|
||||
draftTag.versions.collectLatest {
|
||||
// don't save the first
|
||||
if (it > 0) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
val tag = draftTag.current
|
||||
draftNote = account.getOrCreateDraftNote(tag)
|
||||
accountViewModel.launchSigner {
|
||||
sendDraftSync()
|
||||
// Post rotates the tag and then clears the composer. A save still queued from
|
||||
// before that would see the empty text and delete the draft Post just saved
|
||||
// for the post that is still mining, so it's skipped once the tag has moved on.
|
||||
if (draftTag.current == tag) sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,6 +407,16 @@ open class ChannelNewMessageViewModel :
|
||||
// the composer, so the user keeps their text (and draft) to retry rather than losing it silently.
|
||||
if (channel is GeohashChatChannel && channelRelays.isEmpty()) return
|
||||
|
||||
// A mined post leaves the phone minutes after this returns — much later if the
|
||||
// app is backgrounded and frozen — and the draft is its only user-visible copy
|
||||
// until then. The auto-save is debounced and cancel() below drops the pending
|
||||
// save, so the last second of typing would never reach it: flush it now.
|
||||
// Geohash posts mine inline and drop the draft right away, so only the queued path needs it.
|
||||
if (channel !is GeohashChatChannel && accountViewModel.account.powDifficultyFor(template.kind) != null) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
sendDraftSync()
|
||||
}
|
||||
|
||||
val draftToDelete = draftNote
|
||||
onUiThread { cancel() }
|
||||
|
||||
|
||||
+15
-2
@@ -147,9 +147,13 @@ class LongFormPostViewModel :
|
||||
draftTag.versions.collectLatest {
|
||||
// don't save the first
|
||||
if (it > 0) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
val tag = draftTag.current
|
||||
draftNote = account.getOrCreateDraftNote(tag)
|
||||
accountViewModel.launchSigner {
|
||||
sendDraftSync()
|
||||
// Post rotates the tag and then clears the composer. A save still queued from
|
||||
// before that would see the empty text and delete the draft Post just saved
|
||||
// for the post that is still mining, so it's skipped once the tag has moved on.
|
||||
if (draftTag.current == tag) sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,6 +360,15 @@ class LongFormPostViewModel :
|
||||
suspend fun sendPostSync() {
|
||||
val template = createTemplate() ?: return
|
||||
|
||||
// A mined post leaves the phone minutes after this returns — much later if the
|
||||
// app is backgrounded and frozen — and the draft is its only user-visible copy
|
||||
// until then. The auto-save is debounced and cancel() below drops the pending
|
||||
// save, so the last second of typing would never reach it: flush it now.
|
||||
if (accountViewModel.account.powDifficultyFor(template.kind) != null) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
sendDraftSync()
|
||||
}
|
||||
|
||||
val draftToDelete = draftNote
|
||||
onUiThread { cancel() }
|
||||
|
||||
|
||||
+27
-7
@@ -228,9 +228,13 @@ open class ShortNotePostViewModel :
|
||||
draftTag.versions.collectLatest {
|
||||
// don't save the first
|
||||
if (it > 0) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
val tag = draftTag.current
|
||||
draftNote = account.getOrCreateDraftNote(tag)
|
||||
accountViewModel.launchSigner {
|
||||
sendDraftSync()
|
||||
// Post rotates the tag and then clears the composer. A save still queued from
|
||||
// before that would see the empty text and delete the draft Post just saved
|
||||
// for the post that is still mining, so it's skipped once the tag has moved on.
|
||||
if (draftTag.current == tag) sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1140,13 +1144,27 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
// captured before cancel() resets the chip
|
||||
val chosenPow = powOverride
|
||||
|
||||
// A mined post leaves the phone minutes after this returns — much later if the
|
||||
// app is backgrounded and frozen — and the draft is its only user-visible copy
|
||||
// until then. The auto-save is debounced and cancel() below drops the pending
|
||||
// save, so the last second of typing would never reach it: flush it now.
|
||||
// A private note mines its gift wraps, not the kind-1 itself.
|
||||
val minedKind = if (wantsPrivateNote && template.kind == TextNoteEvent.KIND) GiftWrapEvent.KIND else template.kind
|
||||
if (accountViewModel.settings.automaticallyCreateDrafts() && accountViewModel.account.powDifficultyFor(minedKind, chosenPow) != null) {
|
||||
draftNote = account.getOrCreateDraftNote(draftTag.current)
|
||||
// the same template sendDraftSync() would rebuild; reuse it.
|
||||
val attachments = nip95attachments.flatMapTo(mutableSetOf()) { listOf(it.first, it.second) }
|
||||
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
|
||||
}
|
||||
|
||||
val draftToDelete = draftNote
|
||||
val anonymous = wantsAnonymousPost
|
||||
val scheduledFor = scheduledForSec
|
||||
val privately = wantsPrivateNote
|
||||
val threadTarget = groupThreadTarget
|
||||
// captured before cancel() resets the chip
|
||||
val chosenPow = powOverride
|
||||
onUiThread { cancel() }
|
||||
|
||||
// Draft deletion lives INSIDE each publish continuation: when the post
|
||||
@@ -1170,10 +1188,12 @@ open class ShortNotePostViewModel :
|
||||
// reply must never fall through to a public publish path (the UI
|
||||
// hides those toggles while private mode is on). The inner note and
|
||||
// seals are signed inline; only wrap mining is queued — the content
|
||||
// is committed (and checkpointed) by the time this returns.
|
||||
// is committed (and checkpointed) by the time this returns. The draft
|
||||
// goes only once the wraps are sent, like the public paths.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
accountViewModel.account.sendPrivateNote(template as EventTemplate<TextNoteEvent>, chosenPow)
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
|
||||
accountViewModel.account.sendPrivateNote(template as EventTemplate<TextNoteEvent>, chosenPow) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -623,6 +623,7 @@
|
||||
<item quantity="other">Mining proof of work… (%1$d posts in queue)</item>
|
||||
</plurals>
|
||||
<string name="pow_mining_title">Mining proof of work</string>
|
||||
<string name="pow_notification_sending">Sending to relays…</string>
|
||||
<string name="pow_notification_cancel_all">Cancel</string>
|
||||
<string name="pow_time_left">≈ %1$s left</string>
|
||||
<string name="pow_time_left_soon">any moment now</string>
|
||||
|
||||
Reference in New Issue
Block a user