diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 72bdeefe46..e8035be81f 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -346,6 +346,15 @@ android:stopWithTask="true" android:exported="false" /> + + + + loaded.values.forEach { powJobRestorer.restore(it) } + } + } + // Evict the BlossomServerResolver URL cache whenever either local-cache // toggle flips or the probe transitions up/down so stale entries don't // outlive the underlying decision. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9e925eab69..92b733913a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -57,6 +57,11 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy +import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache @@ -198,9 +203,12 @@ 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.nip10Notes.threadRootIdOrSelf +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.signer.PoWNostrSigner import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -216,6 +224,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent @@ -241,6 +250,7 @@ import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkLi import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -259,6 +269,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo @@ -280,6 +291,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -343,6 +355,7 @@ class Account( val mlsGroupStateStore: MlsGroupStateStore? = null, val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, + val powQueue: () -> PoWPublishQueue? = { null }, ) : IAccount { private var userProfileCache: User? = null @@ -711,6 +724,21 @@ class Account( return false } + suspend fun updatePowDifficulty(difficulty: Int) { + if (settings.updatePowDifficulty(difficulty)) { + sendNewAppSpecificData() + } + } + + suspend fun updatePowCategory( + category: PoWCategory, + enabled: Boolean, + ) { + if (settings.updatePowCategory(category, enabled)) { + sendNewAppSpecificData() + } + } + suspend fun updateFilterSpam(filterSpam: Boolean): Boolean { if (settings.updateFilterSpam(filterSpam)) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { @@ -812,17 +840,233 @@ class Account( private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData()) + // --- + // NIP-13 proof-of-work publishing + // --- + + /** + * Difficulty to mine [kind] at per this account's NIP-13 settings, or null + * when the kind publishes immediately: master difficulty off, category + * disabled, or one of [PoWPolicy]'s hard-excluded kinds (auth, zap + * requests, NWC/bunker RPC, drafts, lists…). + */ + fun powDifficultyFor(kind: Int): Int? = + PoWPolicy.shouldMine( + kind = kind, + difficulty = settings.syncedSettings.proofOfWork.difficulty.value, + enabledCategories = settings.syncedSettings.proofOfWork.enabledCategories.value, + ) + + /** + * [powDifficultyFor] with a per-post override from the composer chip: + * null defers to the account settings, 0 disables mining for this post, + * a positive value forces that difficulty (hard-excluded kinds still win). + */ + fun powDifficultyFor( + kind: Int, + overrideDifficulty: Int?, + ): Int? = + when { + overrideDifficulty == null -> powDifficultyFor(kind) + overrideDifficulty <= 0 -> null + PoWPolicy.neverMine(kind) -> null + else -> overrideDifficulty + } + + /** + * Enqueues [work] into the fire-and-forget mining queue. Returns false when + * no queue is wired (headless/test accounts): callers must then run their + * direct, un-mined send path instead. + */ + fun mineInBackground( + kind: Int, + difficulty: Int, + work: suspend (isActive: () -> Boolean) -> Unit, + ): Boolean { + val queue = powQueue() ?: return false + queue.enqueueWork(kind, difficulty, owner = signer.pubKey, work = work) + return true + } + + /** + * Enqueues [template] to be mined at [difficulty] and then handed to + * [onMined], which should run the exact sign+send path the caller would + * have used without PoW. Returns false when no queue is wired. + * + * When [replay] is given the job is checkpointed to disk so it survives + * process death: on the next login the restorer re-mines the persisted + * template and finishes it with the (headless) replay path instead of + * [onMined]. Pass null for content that must not touch disk. + * + * The template is normalized to the final tag shape the signer will submit + * (client tag included) before mining — a tag appended after mining would + * invalidate the nonce. + */ + fun mineTemplateInBackground( + template: EventTemplate, + difficulty: Int, + replay: PoWReplay? = null, + onMined: suspend (EventTemplate) -> Unit, + ): Boolean { + val queue = powQueue() ?: return false + val finalTemplate = withFinalSignerTags(template) + val record = replay?.toRecord(RandomInstance.randomChars(16), signer.pubKey, finalTemplate, difficulty) + queue.enqueue( + template = finalTemplate, + pubKey = signer.pubKey, + difficulty = difficulty, + persistAs = record, + // NIP-13 recommends refreshing created_at while mining; scheduled + // posts keep their intentional future timestamp. + refreshCreatedAtOnStart = replay !is PoWReplay.Schedule, + onMined = onMined, + ) + return true + } + + /** + * The one-liner for template send paths: when [template]'s kind should be + * mined (per settings and the optional composer [overrideDifficulty]), + * enqueue it and run [send] with the mined template once the nonce is + * found; otherwise run [send] with [template] right now. + */ + suspend fun sendMined( + template: EventTemplate, + replay: PoWReplay?, + overrideDifficulty: Int? = null, + send: suspend (EventTemplate) -> Unit, + ) { + val difficulty = powDifficultyFor(template.kind, overrideDifficulty) + if (difficulty == null || !mineTemplateInBackground(template, difficulty, replay, send)) { + send(template) + } + } + + /** + * Queues wrap mining for pre-signed [seals] (see NIP17Factory.createSeals): + * each seal gets its ephemeral-key envelope mined at [difficulty] on the + * worker pool, then the wraps broadcast. Checkpointed under + * [PersistedPoWJob.REPLAY_WRAPS] (the seals are already-signed ciphertext, + * safe to persist) unless an [existingRecord] from the restorer is passed. + * Returns false when no queue is wired. + */ + fun mineWrapsInBackground( + seals: List, + expirationDelta: Long?, + difficulty: Int, + existingRecord: PersistedPoWJob? = null, + ): Boolean { + val queue = powQueue() ?: return false + if (seals.isEmpty()) return true + + val record = + existingRecord + ?: PersistedPoWJob( + id = RandomInstance.randomChars(16), + accountPubkey = signer.pubKey, + kind = GiftWrapEvent.KIND, + difficulty = difficulty, + templateJson = "", + replayType = PersistedPoWJob.REPLAY_WRAPS, + extraEventsJson = seals.map { it.seal.toJson() }, + recipientPubkeys = seals.map { it.recipient }, + wrapExpirationDelta = expirationDelta, + createdAtSec = TimeUtils.now(), + ) + + queue.enqueueStaged( + kind = GiftWrapEvent.KIND, + difficulty = difficulty, + persistAs = record, + mine = { isActive -> + // the wrap's ephemeral key is generated inside the wrap build; + // the conversion hook hands its pubkey back so the nonce can + // commit to it. + val mineWrap: GiftWrapTemplateConversion = { template, ephemeralPubKey -> + PoWMiner.run(template, ephemeralPubKey, difficulty, isActive) + } + seals.map { NIP17Factory().wrapSeal(it, expirationDelta, templateConversion = mineWrap) } + }, + publish = { wraps -> broadcastPrivately(wraps) }, + ) + return true + } + + private fun withFinalSignerTags(template: EventTemplate): EventTemplate { + val currentSigner = signer + if (currentSigner !is NostrSignerWithClientTag) return template + + val finalTags = currentSigner.prepareTags(template.tags) + if (finalTags === template.tags) return template + + return EventTemplate(template.createdAt, template.kind, finalTags, template.content) + } + + /** + * A signer that mines [kindsToMine] at [difficulty] right before signing. + * When the account signer stamps a client tag, the miner is layered inside + * it so mining runs over the final tag set. + */ + private fun miningSigner( + difficulty: Int, + kindsToMine: Set, + isActive: () -> Boolean, + ): NostrSigner { + val currentSigner = signer + return if (currentSigner is NostrSignerWithClientTag) { + NostrSignerWithClientTag( + inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive), + clientTag = currentSigner.clientTag, + disabled = currentSigner.disabled, + ) + } else { + PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive) + } + } + suspend fun reactTo( note: Note, reaction: String, - ) = ReactionAction.reactTo( - note = note, - reaction = reaction, - by = userProfile(), - signer = signer, - onPublic = ::sendAutomatic, - onPrivate = ::broadcastPrivately, - ) + ) { + // Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the + // inner kind-7 only ever travels as ciphertext, so mining it is pure + // waste — those targets skip the queue and sign with the plain signer. + val isPrivateTarget = note.event is NIP17Group || note.isPrivateRumor() + + val powDifficulty = if (isPrivateTarget) null else powDifficultyFor(ReactionEvent.KIND) + if (powDifficulty != null) { + val queue = powQueue() + if (queue != null) { + // toggle semantics while mining: a second tap on the same + // reaction un-likes by cancelling the pending job instead of + // publishing a duplicate (the mined event doesn't exist yet, + // so hasReacted can't dedupe). + val dedupeKey = "reaction:${note.idHex}:$reaction" + if (queue.cancelByKey(dedupeKey)) return + + queue.enqueueWork(ReactionEvent.KIND, powDifficulty, dedupeKey, owner = signer.pubKey) { isActive -> + ReactionAction.reactTo( + note = note, + reaction = reaction, + by = userProfile(), + signer = miningSigner(powDifficulty, setOf(ReactionEvent.KIND), isActive), + onPublic = ::sendAutomatic, + onPrivate = ::broadcastPrivately, + ) + } + return + } + } + + ReactionAction.reactTo( + note = note, + reaction = reaction, + by = userProfile(), + signer = signer, + onPublic = ::sendAutomatic, + onPrivate = ::broadcastPrivately, + ) + } /** * Creates a reaction event without sending it. @@ -1080,16 +1324,41 @@ class Account( // A kind-1984 e-tagging the rumor would leak the private id onto // public relays. Report the author instead (p-tag only). note.author?.let { report(it, type, content) } - } else { - sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + return } + + val powDifficulty = powDifficultyFor(ReportEvent.KIND) + if (powDifficulty != null && + mineInBackground(ReportEvent.KIND, powDifficulty) { isActive -> + sendMyPublicAndPrivateOutbox( + ReportAction.report(note, type, content, userProfile(), miningSigner(powDifficulty, setOf(ReportEvent.KIND), isActive)), + ) + } + ) { + return + } + + sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) } suspend fun report( user: User, type: ReportType, content: String = "", - ) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer)) + ) { + val powDifficulty = powDifficultyFor(ReportEvent.KIND) + if (powDifficulty != null && + mineInBackground(ReportEvent.KIND, powDifficulty) { isActive -> + sendMyPublicAndPrivateOutbox( + ReportAction.report(user, type, content, userProfile(), miningSigner(powDifficulty, setOf(ReportEvent.KIND), isActive)), + ) + } + ) { + return + } + + sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer)) + } suspend fun delete(note: Note) = delete(listOf(note)) @@ -1165,7 +1434,23 @@ class Account( ) = blossomServers.createBlossomDeleteAuth(hash, alt) suspend fun boost(note: Note) { - RepostAction.repost(note, signer)?.let { event -> + val powDifficulty = powDifficultyFor(RepostEvent.KIND) + if (powDifficulty != null && + mineInBackground(RepostEvent.KIND, powDifficulty) { isActive -> + repostNow(note, miningSigner(powDifficulty, setOf(RepostEvent.KIND, GenericRepostEvent.KIND), isActive)) + } + ) { + return + } + + repostNow(note, signer) + } + + private suspend fun repostNow( + note: Note, + repostSigner: NostrSigner, + ) { + RepostAction.repost(note, repostSigner)?.let { event -> client.publish(event, computeMyReactionToNote(note, event)) cache.justConsumeMyOwnEvent(event) } @@ -2921,18 +3206,38 @@ class Account( // broadcastPrivately) instead of waiting for the newEventBundles // batcher; the later batched re-delivery is deduped by the chatroom. cache.getNoteIfExists(newEvent.id)?.let { newNotesPreProcessor.consume(it) } + + markDmRoomAsRead(newEvent) } override suspend fun sendNip17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return - val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer) - broadcastPrivately(wraps) + val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND) + if (powDifficulty != null) { + // Sign the inner event and every seal NOW, in the caller's + // interaction context — an external signer (Amber/bunker) cannot + // prompt from a background mining worker. Only the local-CPU + // ephemeral-key wrap mining goes to the queue, checkpointed so a + // process death mid-mine cannot lose the file announcement. + val senderMessage = signer.sign(template) + val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer) + if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) return + } + + broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer)) } override suspend fun sendNip17PrivateMessage(template: EventTemplate) { - val events = NIP17Factory().createMessageNIP17(template, signer) - broadcastPrivately(events) + 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)) return + } + + broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer)) } /** @@ -2941,9 +3246,25 @@ class Account( * to the recipient's DM relays. Used for private replies (the parent's * author and participants are already p-tagged) and for private posts * (the Notify list is the audience). Nothing reaches public relays. + * + * [powOverrideDifficulty] is the composer chip's per-post override: + * null follows the account's gift-wrap setting, 0 disables mining. */ - suspend fun sendPrivateNote(template: EventTemplate) { + suspend fun sendPrivateNote( + template: EventTemplate, + powOverrideDifficulty: Int? = null, + ) { if (!isWriteable()) return + + val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND, powOverrideDifficulty) + if (powDifficulty != null) { + // See sendNip17EncryptedFile: sign inline, queue only wrap mining. + 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)) return + } + broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer)) } @@ -2954,8 +3275,10 @@ class Account( } } - suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) { - val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } + suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) = broadcastPrivately(signedEvents.wraps) + + suspend fun broadcastPrivately(wraps: List) { + val mine = wraps.filter { (it.recipientPubKey() == signer.pubKey) } mine.forEach { giftWrap -> cache.justConsumeMyOwnEvent(giftWrap) @@ -2964,7 +3287,7 @@ class Account( val id = mine.firstOrNull()?.id val mineNote = if (id == null) null else cache.getNoteIfExists(id) - signedEvents.wraps.forEach { wrap -> + wraps.forEach { wrap -> // Creates an alias if (mineNote != null && wrap.recipientPubKey() != signer.pubKey) { cache.getOrAddAliasNote(wrap.id, mineNote) @@ -2981,6 +3304,28 @@ class Account( // batcher re-delivers this note later; the processor's replay path and // the chatroom add are both idempotent. mineNote?.let { newNotesPreProcessor.consume(it) } + + markDmRoomAsRead(signedEvents.msg) + } + + /** + * Sending a message into a DM room means the user has caught up with what the room + * showed when they replied: advance the local read marker to the newest known message — + * not just the sent one, whose local clock may lag behind a skew-ahead peer's — so the + * unread indicators clear without requiring the conversation to be reopened + * (#1286, #1287). No-op for private events that don't belong to a room (private notes, + * reactions, deletions). + */ + private fun markDmRoomAsRead(event: Event) { + if (event is ChatroomKeyable) { + val room = event.chatroomKey(signer.pubKey) + val newestInRoom = + chatroomList.rooms + .get(room) + ?.newestMessage + ?.createdAt() ?: 0L + markAsRead(privateChatLastReadRoute(room), maxOf(event.createdAt, newestInRoom)) + } } // --- Marmot Group Messaging --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 965ec6c886..5f9799dbdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -589,6 +590,25 @@ class AccountSettings( false } + fun updatePowDifficulty(difficulty: Int): Boolean = + if (syncedSettings.proofOfWork.updateDifficulty(difficulty)) { + saveAccountSettings() + true + } else { + false + } + + fun updatePowCategory( + category: PoWCategory, + enabled: Boolean, + ): Boolean = + if (syncedSettings.proofOfWork.updateCategory(category, enabled)) { + saveAccountSettings() + true + } else { + false + } + // --- // list names // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index acb4dfd8ff..45310e9073 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -72,6 +74,11 @@ class AccountSyncedSettings( AccountChatPreferences( MutableStateFlow(internalSettings.chats.toChatroomKeys()), ) + val proofOfWork = + AccountPoWPreferences( + MutableStateFlow(internalSettings.proofOfWork.difficulty), + MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)), + ) fun toInternal(): AccountSyncedSettingsInternal = AccountSyncedSettingsInternal( @@ -104,6 +111,14 @@ class AccountSyncedSettings( videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name), chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }), + proofOfWork = + AccountPoWPreferencesInternal( + proofOfWork.difficulty.value, + // sorted so the serialized form is deterministic + proofOfWork.enabledCategories.value + .map { it.id } + .sorted(), + ), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -182,6 +197,19 @@ class AccountSyncedSettings( if (chats.pinnedChatrooms.value != newPinnedChatrooms) { chats.pinnedChatrooms.tryEmit(newPinnedChatrooms) } + + // clamp like the local setter: a synced NIP-78 event from another + // client could carry an out-of-range value that would crash the miner + // (>256) or mine forever (41+). + val newDifficulty = syncedSettingsInternal.proofOfWork.difficulty.coerceIn(0, PoWPolicy.MAX_DIFFICULTY) + if (proofOfWork.difficulty.value != newDifficulty) { + proofOfWork.difficulty.tryEmit(newDifficulty) + } + + val newPoWCategories = PoWCategory.fromIds(syncedSettingsInternal.proofOfWork.enabledCategories) + if (proofOfWork.enabledCategories.value != newPoWCategories) { + proofOfWork.enabledCategories.tryEmit(newPoWCategories) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -285,6 +313,43 @@ class AccountChatPreferences( val pinnedChatrooms: MutableStateFlow>, ) +@Stable +class AccountPoWPreferences( + val difficulty: MutableStateFlow = MutableStateFlow(0), + val enabledCategories: MutableStateFlow> = MutableStateFlow(PoWCategory.DEFAULT_ENABLED), +) { + fun updateDifficulty(newDifficulty: Int): Boolean { + // compare the coerced value: reporting a change for an out-of-range + // input that clamps to the current value would republish identical + // settings to relays. + val coerced = newDifficulty.coerceIn(0, MAX_POW_DIFFICULTY) + return if (difficulty.value != coerced) { + difficulty.tryEmit(coerced) + true + } else { + false + } + } + + fun updateCategory( + category: PoWCategory, + enabled: Boolean, + ): Boolean { + val current = enabledCategories.value + val updated = if (enabled) current + category else current - category + return if (updated != current) { + enabledCategories.tryEmit(updated) + true + } else { + false + } + } + + companion object { + const val MAX_POW_DIFFICULTY = PoWPolicy.MAX_DIFFICULTY + } +} + internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 800f4528d1..3de1f9b27c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources import androidx.core.os.ConfigurationCompat +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.serialization.Serializable import java.util.Locale @@ -157,6 +158,7 @@ class AccountSyncedSettingsInternal( val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(), val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(), val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(), + val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(), ) @Serializable @@ -206,6 +208,14 @@ class AccountMediaPreferencesInternal( var audioVisualizer: String = "CLASSIC", ) +@Serializable +class AccountPoWPreferencesInternal( + // NIP-13 target difficulty in leading zero bits; 0 = don't mine anything. + val difficulty: Int = 0, + // PoWCategory ids the user wants mined when difficulty > 0. + val enabledCategories: List = PoWCategory.DEFAULT_ENABLED.map { it.id }, +) + @Serializable class AccountChatPreferencesInternal( // Rooms pinned to the top of the chat list. Each room is its member diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadState.kt new file mode 100644 index 0000000000..24e2d00d86 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadState.kt @@ -0,0 +1,63 @@ +/* + * 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.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable + +/** + * Route key under which a private chat room's last-read time is stored in AccountSettings. + * Every marker writer (send paths, ingestion, room view, hidden-room sweep) and reader + * (Messages-tab dot, room-row bubble) must build the key through this function: a format + * drift between a writer and a reader silently splits read state (#1286). + */ +fun privateChatLastReadRoute(room: ChatroomKey) = "Room/${room.hashCode()}" + +/** + * True when [message] marks [room] as read up to its timestamp: the logged-in user authored + * it, so sending it — from this device, or from another one arriving via the self-addressed + * gift wrap — means they had caught up with the conversation (#1286, #1287). Notes-to-self + * rooms are exempt: there the user's own messages ARE the content still to be seen. + */ +fun chatMessageMarksRoomAsRead( + message: Event, + room: ChatroomKey, + loggedInUser: HexKey, +): Boolean = message.pubKey == loggedInUser && room.users.singleOrNull() != loggedInUser + +/** + * Read-marker route + timestamp for the newest message of a private chat room, or null when + * the room cannot be unread: no chat event, a newest message that counts as read (see + * [chatMessageMarksRoomAsRead]), or every participant hidden. + */ +fun unreadPrivateChatRoute( + newestMessage: Event?, + loggedInUser: HexKey, + isAllHidden: (Set) -> Boolean, +): Pair? { + if (newestMessage !is ChatroomKeyable) return null + val room = newestMessage.chatroomKey(loggedInUser) + if (chatMessageMarksRoomAsRead(newestMessage, room, loggedInUser)) return null + if (isAllHidden(room.users)) return null + return privateChatLastReadRoute(room) to newestMessage.createdAt +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index d307f3a07b..32a6183eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache @@ -61,6 +62,7 @@ class AccountCacheState( val cache: LocalCache, val client: INostrClient, val rootFilesDir: () -> File = { File("") }, + val powQueue: () -> PoWPublishQueue? = { null }, ) { val accounts = MutableStateFlow>(emptyMap()) @@ -244,6 +246,7 @@ class AccountCacheState( mlsGroupStateStore = mlsStore, marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, + powQueue = powQueue, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt new file mode 100644 index 0000000000..fa4eef5166 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt @@ -0,0 +1,72 @@ +/* + * 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.service.pow + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.stringRes +import kotlin.math.roundToLong + +/** + * "45 seconds" / "10 minutes" / "3 hours" — the one human-readable rendering + * of a PoW duration estimate, shared by the settings picker, the composer + * difficulty menu, the mining banner, and the mining notification. + * + * Estimates come from [com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator] + * and are the statistical mean of a memoryless search — any single post can be + * much luckier or unluckier, so always present these as approximations. + */ +fun formatApproxDuration( + context: Context, + seconds: Double, +): String { + fun quantity( + id: Int, + count: Long, + ) = pluralStringRes(context, id, count.toInt(), count.toInt()) + + return when { + seconds < 1.0 -> stringRes(context, R.string.pow_estimate_instant) + seconds < 90.0 -> quantity(R.plurals.pow_estimate_seconds, seconds.roundToLong()) + seconds < 90.0 * 60.0 -> quantity(R.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong()) + seconds < 48.0 * 3600.0 -> quantity(R.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong()) + else -> quantity(R.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong()) + } +} + +/** + * "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean, + * "any moment now" once past it — a memoryless search has no shrinking + * remainder, so past the mean the only honest claim is "soon". + */ +fun formatTimeLeft( + context: Context, + expectedSec: Double, + elapsedSec: Long, +): String { + val remaining = expectedSec - elapsedSec + return if (remaining > 1.0) { + stringRes(context, R.string.pow_time_left, formatApproxDuration(context, remaining)) + } else { + stringRes(context, R.string.pow_time_left_soon) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt new file mode 100644 index 0000000000..9341cb5109 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -0,0 +1,180 @@ +/* + * 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.service.pow + +import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.utils.Log +import java.util.UUID + +/** + * Re-enqueues the mining jobs checkpointed by [PowJobStore] when an account + * logs in, replacing the lost in-memory continuation with the headless replay + * described by each record. Restore is idempotent: the queue dedupes by job + * id, so a login flow that emits twice cannot double-mine. + */ +class PowJobRestorer( + private val queue: PoWPublishQueue, + private val store: PowJobStore, + private val scheduledPostStore: ScheduledPostStore, +) { + suspend fun restore(account: Account) { + val records = store.listFor(account.signer.pubKey) + if (records.isEmpty()) return + + Log.d(TAG) { "Restoring ${records.size} pending PoW job(s) for ${account.signer.pubKey.take(8)}…" } + + records.forEach { record -> + if (record.difficulty <= 0) { + store.remove(record.id) + return@forEach + } + + if (record.replayType == PersistedPoWJob.REPLAY_WRAPS) { + restoreWraps(account, record) + return@forEach + } + + val template = + try { + EventTemplate.fromJson(record.templateJson) + } catch (e: Exception) { + Log.w(TAG, "Dropping unreadable PoW job ${record.id}", e) + store.remove(record.id) + return@forEach + } + + queue.enqueue( + template = template, + pubKey = account.signer.pubKey, + difficulty = record.difficulty, + persistAs = record, + // a restored job may be hours old; publish with a fresh + // created_at (NIP-13 recommendation) — except scheduled posts, + // whose future created_at is the point. + refreshCreatedAtOnStart = record.replayType != PersistedPoWJob.REPLAY_SCHEDULE, + ) { mined -> + replay(account, record, mined) + } + } + } + + /** + * Wrap jobs carry no template — the pre-signed seals live in + * [PersistedPoWJob.extraEventsJson], one recipient per seal in + * [PersistedPoWJob.recipientPubkeys]. Re-enqueueing goes through the same + * [Account.mineWrapsInBackground] path the send used, with the existing + * record so the checkpoint id (and restore idempotence) is preserved. + */ + private fun restoreWraps( + account: Account, + record: PersistedPoWJob, + ) { + if (record.extraEventsJson.size != record.recipientPubkeys.size) { + Log.w(TAG) { "Dropping malformed wrap PoW job ${record.id}: ${record.extraEventsJson.size} seal(s) vs ${record.recipientPubkeys.size} recipient(s)" } + store.remove(record.id) + return + } + + val seals = + record.extraEventsJson.zip(record.recipientPubkeys).mapNotNull { (sealJson, recipient) -> + try { + NIP17Factory.AddressedSeal(recipient = recipient, seal = Event.fromJson(sealJson)) + } catch (e: Exception) { + Log.w(TAG, "Dropping unreadable seal of wrap PoW job ${record.id}", e) + null + } + } + + if (seals.isEmpty()) { + store.remove(record.id) + return + } + + account.mineWrapsInBackground( + seals = seals, + expirationDelta = record.wrapExpirationDelta, + difficulty = record.difficulty, + existingRecord = record, + ) + } + + private suspend fun replay( + account: Account, + record: PersistedPoWJob, + mined: EventTemplate, + ) { + val extras = + record.extraEventsJson.mapNotNull { + try { + Event.fromJson(it) + } catch (e: Exception) { + Log.w(TAG, "Dropping unreadable extra event of PoW job ${record.id}", e) + null + } + } + + when (record.replayType) { + PersistedPoWJob.REPLAY_BROADCAST -> { + account.signAndComputeBroadcast(mined, extras) + } + + PersistedPoWJob.REPLAY_RELAYS -> { + val relays = record.relayUrls.map { NormalizedRelayUrl(it) } + account.signAndSendPrivatelyOrBroadcast(mined) { relays } + } + + PersistedPoWJob.REPLAY_SCHEDULE -> { + val publishAtSec = record.publishAtSec + if (publishAtSec == null) { + Log.w(TAG) { "Scheduled PoW job ${record.id} has no publish time; broadcasting instead" } + account.signAndComputeBroadcast(mined, extras) + return + } + val (event, relays, extraList) = account.createPostEvent(mined, extras) + scheduledPostStore.add( + ScheduledPost( + id = UUID.randomUUID().toString(), + accountPubkey = event.pubKey, + signedEventJson = event.toJson(), + relayUrls = relays.map { it.url }, + extraEventsJson = extraList.map { it.toJson() }, + publishAtSec = publishAtSec, + createdAtSec = System.currentTimeMillis() / 1000, + ), + ) + } + + else -> Log.w(TAG) { "Unknown replay type '${record.replayType}' for PoW job ${record.id}; dropping" } + } + } + + companion object { + private const val TAG = "PowJobRestorer" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt new file mode 100644 index 0000000000..e20ee660d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt @@ -0,0 +1,153 @@ +/* + * 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.service.pow + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * On-disk checkpoint of pending PoW mining jobs so posts survive process + * death: the queue upserts on enqueue and removes on finish/cancel, and + * [PowJobRestorer] re-enqueues whatever is left when an account logs in. + * + * [save]/[remove] are the queue-facing fire-and-forget hooks; they serialize + * onto a single-lane dispatcher so writes land in call order. + */ +class PowJobStore( + private val storageFile: File, + scope: CoroutineScope, +) : PoWJobPersistence { + private val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + // one lane: launch order == execution order, so a save followed by its + // remove can never be applied backwards. + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + private val writeLane = Dispatchers.IO.limitedParallelism(1) + private val writeScope = CoroutineScope(scope.coroutineContext + writeLane) + + private val mutex = Mutex() + private var loaded = false + private var jobs: MutableList = mutableListOf() + + override fun save(job: PersistedPoWJob) { + writeScope.launch { + mutex.withLock { + ensureLoaded() + jobs.removeAll { it.id == job.id } + jobs.add(job) + persist() + } + } + } + + override fun remove(jobId: String) { + writeScope.launch { + mutex.withLock { + ensureLoaded() + if (jobs.removeAll { it.id == jobId }) persist() + } + } + } + + suspend fun listFor(accountPubkey: String): List = + withContext(writeLane) { + mutex.withLock { + ensureLoaded() + jobs.filter { it.accountPubkey == accountPubkey } + } + } + + /** Drops every record owned by [accountPubkey] (account deletion). */ + fun removeForAccount(accountPubkey: String) { + writeScope.launch { + mutex.withLock { + ensureLoaded() + if (jobs.removeAll { it.accountPubkey == accountPubkey }) persist() + } + } + } + + private fun ensureLoaded() { + if (loaded) return + jobs = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile).jobs.toMutableList() + } else { + mutableListOf() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load pending PoW jobs from $storageFile", e) + mutableListOf() + } + loaded = true + val cutoff = System.currentTimeMillis() / 1000 - MAX_AGE_SEC + if (jobs.removeAll { it.createdAtSec in 1 until cutoff }) persist() + } + + private fun persist() { + storageFile.parentFile?.mkdirs() + val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + try { + mapper.writeValue(tmp, PowJobsFile(version = 1, jobs = jobs.toList())) + if (!tmp.renameTo(storageFile)) { + if (!storageFile.delete() || !tmp.renameTo(storageFile)) { + Log.e(TAG) { "Failed to rename $tmp to $storageFile" } + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to persist pending PoW jobs to $storageFile", e) + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" } + } + } + } + + companion object { + private const val TAG = "PowJobStore" + const val FILE_NAME = "pending_pow_jobs.json" + + // a job this stale is a post the user has long forgotten; publishing + // it a week later would be more surprising than dropping it. + private const val MAX_AGE_SEC = 3L * 24 * 3600 + } +} + +data class PowJobsFile( + val version: Int = 1, + val jobs: List = emptyList(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowKindLabel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowKindLabel.kt new file mode 100644 index 0000000000..1e91ba492e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowKindLabel.kt @@ -0,0 +1,51 @@ +/* + * 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.service.pow + +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent + +/** + * The one user-facing label for "what is being mined": shared by the + * broadcast banner, the mining foreground notification, and failure toasts + * so a job is described the same way everywhere it appears. + */ +@StringRes +fun powKindLabelRes(kind: Int): Int = + when (kind) { + ReactionEvent.KIND -> R.string.reaction + RepostEvent.KIND, GenericRepostEvent.KIND -> R.string.boost + VoiceEvent.KIND -> R.string.voice_post + VoiceReplyEvent.KIND -> R.string.voice_reply + ReportEvent.KIND -> R.string.pow_kind_report + GiftWrapEvent.KIND -> R.string.private_message + ChannelMessageEvent.KIND, LiveActivitiesChatMessageEvent.KIND -> R.string.pow_kind_chat_message + else -> R.string.post + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt new file mode 100644 index 0000000000..36fe373b50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -0,0 +1,319 @@ +/* + * 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.service.pow + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +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.PoWJobState +import com.vitorpamplona.amethyst.ui.MainActivity +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.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Short-lived foreground service that shields the PoW mining queue from the + * cached-apps freezer: while it runs the process stays schedulable, so posts + * finish mining even after the user backgrounds the app. + * + * Uses the Android 14+ `shortService` type — no special permission, but a + * hard ~3 minute budget. On [onTimeout] the service exits cleanly; every + * persistable job is already checkpointed by [PowJobStore], so anything still + * 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. + * + * The notification is a live progress card ([NotificationCompat.ProgressStyle]): + * one track segment per post, filling as jobs complete, indeterminate while a + * single post mines, with a cancel-all action. On Android 16+ it renders as a + * Live Updates chip; older versions fall back to a standard progress bar. + */ +class PowMiningForegroundService : Service() { + private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + private var watchJob: Job? = null + + // Session totals so the progress track can show "done / enqueued since the + // service started" — the queue itself only knows what is still pending. + private var sessionTotal = 0 + private var lastQueueSize = 0 + + // Benchmarked once per service run (~250 ms, cached by the estimator); + // read from the notification builder to compute expected durations. + @Volatile + private var hashRate: Double? = null + + // Built once per service instance: the intents never change, and + // buildNotification runs on every queue update. + private val tapIntent: PendingIntent by lazy { + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private val cancelIntent: PendingIntent by lazy { + PendingIntent.getService( + this, + 1, + Intent(this, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + override fun onCreate() { + super.onCreate() + running = true + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + // Android's contract: every onStartCommand after startForegroundService + // must call startForeground promptly, even on the stop path. + runCatching { startForegroundCompat(currentJobs()) } + .onFailure { + Log.w(TAG, "startForeground failed; mining continues without the service", it) + stopSelf() + return START_NOT_STICKY + } + + if (intent?.action == ACTION_CANCEL_ALL) { + Amethyst.instance.powPublishQueue.cancelAll() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + + watchQueue() + return START_NOT_STICKY + } + + /** + * The shortService budget (~3 min) is exhausted. Exit before the system + * ANRs us: persisted jobs are checkpointed and resume on next launch; + * in-memory jobs keep mining opportunistically until the process freezes. + */ + override fun onTimeout(startId: Int) { + Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + running = false + scope.cancel() + super.onDestroy() + } + + private fun currentJobs(): ImmutableList = Amethyst.instance.powPublishQueue.jobs.value + + private fun watchQueue() { + if (watchJob != null) return + watchJob = + scope.launch { + Amethyst.instance.powPublishQueue.jobs.collect { jobs -> + if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize + lastQueueSize = jobs.size + + if (jobs.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } else { + updateNotification(jobs) + } + } + } + + // the estimated-time-left figure and progress fraction only move with + // the clock, not with queue events: benchmark the hash rate once, + // then refresh the card periodically while something is mining. + scope.launch { + hashRate = PoWEstimator.hashesPerSecond() + while (true) { + val jobs = currentJobs() + if (jobs.any { it.isMining }) updateNotification(jobs) + delay(PROGRESS_REFRESH_MS) + } + } + } + + private fun startForegroundCompat(jobs: ImmutableList) { + ensureChannel(this) + val notification = buildNotification(jobs) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun updateNotification(jobs: ImmutableList) { + val manager = NotificationManagerCompat.from(this) + if (!manager.areNotificationsEnabled()) return + try { + manager.notify(NOTIFICATION_ID, buildNotification(jobs)) + } catch (_: SecurityException) { + // POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running. + } + } + + private fun buildNotification(jobs: ImmutableList): Notification { + val done = (sessionTotal - jobs.size).coerceAtLeast(0) + val total = (done + jobs.size).coerceAtLeast(1) + + val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull() + + // expected duration for the job being mined right now, so the card can + // say "≈ 10 minutes left" and fill its bar toward a predictable end. + val rate = hashRate + val startedAt = current?.miningStartedAt + val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null + val elapsedSec = startedAt?.let { (TimeUtils.now() - it).coerceAtLeast(0) } + + val base = + current?.let { + pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelRes(it.kind)), it.difficulty) + } ?: stringRes(this, R.string.pow_mining_title) + val text = + if (expectedSec != null && elapsedSec != null) { + "$base • ${formatTimeLeft(this, expectedSec, elapsedSec)}" + } else { + base + } + + val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null + + val progressStyle: NotificationCompat.ProgressStyle = + if (total <= 1) { + // single post: fill toward the estimated duration; past the + // mean the search is memoryless, so sweep instead of lying. + if (fraction != null && fraction < 1.0) { + NotificationCompat + .ProgressStyle() + .setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100))) + .setProgress((fraction * 100).toInt()) + } else { + NotificationCompat.ProgressStyle().setProgressIndeterminate(true) + } + } else { + NotificationCompat + .ProgressStyle() + .setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) }) + .setProgress(done) + } + + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle( + if (jobs.size > 1) { + pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size) + } else { + stringRes(this, R.string.pow_mining_title) + }, + ).setContentText(text) + .setStyle(progressStyle) + .setContentIntent(tapIntent) + .addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + } + + companion object { + private const val TAG = "PowMiningFgs" + private const val CHANNEL_ID = "pow_mining" + private const val NOTIFICATION_ID = 0x504F57 // "POW" + private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL" + + // clock-driven refresh cadence for the time-left text and bar; the + // shortService budget (~3 min) caps this at a handful of updates. + private const val PROGRESS_REFRESH_MS = 30_000L + + // Best-effort de-dup for start(): the queue calls it on EVERY enqueue, + // and each call otherwise round-trips through system_server. A stale + // false only costs one redundant startForegroundService (which Android + // routes to the existing instance's onStartCommand anyway). + @Volatile + private var running = false + + /** + * Best-effort start: enqueue happens while the user is interacting + * with the app, so the foreground-start allowance normally holds. A + * restore during a cold background launch may be denied — mining then + * proceeds unprotected and the service starts on the next enqueue. + */ + fun start(context: Context) { + if (running) return + try { + context.startForegroundService(Intent(context, PowMiningForegroundService::class.java)) + } catch (e: Exception) { + Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e) + } + } + + private fun ensureChannel(context: Context) { + val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + stringRes(context, R.string.pow_notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = stringRes(context, R.string.pow_notification_channel_description) + setShowBadge(false) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 2fab6dfd26..17ef204ce8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -20,9 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.broadcast +import android.text.format.DateUtils import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -45,10 +50,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -59,6 +71,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState +import com.vitorpamplona.amethyst.service.pow.formatTimeLeft +import com.vitorpamplona.amethyst.service.pow.powKindLabelRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event @@ -72,22 +88,28 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay import java.util.UUID /** * Banner showing active broadcast progress. * Displayed above bottom navigation when events are being sent to relays. + * + * [miningJobs] is the NIP-13 pre-send phase: posts waiting for (or in the + * middle of) proof-of-work mining, before their per-relay send states exist. */ @Composable fun BroadcastBanner( broadcasts: ImmutableList, + miningJobs: ImmutableList = persistentListOf(), + onCancelJob: (String) -> Unit = {}, onTap: () -> Unit = {}, onRetryAll: () -> Unit = {}, onDismiss: () -> Unit = {}, modifier: Modifier = Modifier, ) { AnimatedVisibility( - visible = broadcasts.isNotEmpty(), + visible = broadcasts.isNotEmpty() || miningJobs.isNotEmpty(), enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)), modifier = modifier, @@ -108,19 +130,29 @@ fun BroadcastBanner( .padding(horizontal = 16.dp, vertical = 8.dp) .animateContentSize(), ) { - val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS } + if (miningJobs.isNotEmpty()) { + MiningContent(miningJobs, onCancelJob) - if (isAllFinished) { - if (broadcasts.size == 1) { - CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss) - } else { - MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss) + if (broadcasts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) } - } else { - if (broadcasts.size == 1) { - SingleBroadcastContent(broadcasts.first()) + } + + if (broadcasts.isNotEmpty()) { + val isAllFinished = broadcasts.all { it.status != BroadcastStatus.IN_PROGRESS } + + if (isAllFinished) { + if (broadcasts.size == 1) { + CompletedBroadcastContent(broadcasts.first(), onRetryAll, onDismiss) + } else { + MultipleCompletedBroadcastContent(broadcasts, onRetryAll, onDismiss) + } } else { - MultipleBroadcastsContent(broadcasts) + if (broadcasts.size == 1) { + SingleBroadcastContent(broadcasts.first()) + } else { + MultipleBroadcastsContent(broadcasts) + } } } } @@ -128,6 +160,164 @@ fun BroadcastBanner( } } +@Composable +private fun MiningContent( + miningJobs: ImmutableList, + onCancelJob: (String) -> Unit, +) { + // one shared pulse for the gear — mining has no measurable progress, so + // the animation is what says "the app is working right now". + val pulse = rememberInfiniteTransition(label = "miningPulse") + val gearAlpha by pulse.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(700), + repeatMode = RepeatMode.Reverse, + ), + label = "gearAlpha", + ) + + // 1 Hz clock driving the per-job elapsed labels; only ticks while some + // job actually shows an elapsed time (queued-only banners don't need it). + var nowSec by remember { mutableLongStateOf(TimeUtils.now()) } + val anyMiningStarted = miningJobs.any { it.miningStartedAt != null } + if (anyMiningStarted) { + LaunchedEffect(Unit) { + while (true) { + nowSec = TimeUtils.now() + delay(1_000) + } + } + } + + // Benchmarked once and cached (~250 ms on a worker): turns each job's + // difficulty into an expected duration so the bar has a predictable end. + val context = LocalContext.current + val hashRate by + produceState(initialValue = null) { + value = PoWEstimator.hashesPerSecond() + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + symbol = MaterialSymbols.Manufacturing, + contentDescription = stringRes(R.string.pow_mining_title), + tint = MaterialTheme.colorScheme.primary.copy(alpha = gearAlpha), + modifier = Modifier.size(18.dp), + ) + + Text( + text = pluralStringResource(R.plurals.pow_mining_progress, miningJobs.size, miningJobs.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + miningJobs.forEach { job -> + val elapsedSec = job.miningStartedAt?.let { (nowSec - it).coerceAtLeast(0) } + val expectedSec = hashRate?.let { PoWEstimator.estimateSeconds(job.difficulty, it) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(Modifier.width(26.dp)) + + val base = + pluralStringResource( + if (job.isMining) R.plurals.pow_mining_job else R.plurals.pow_queued_job, + job.difficulty, + kindToName(job.kind), + job.difficulty, + ) + val suffix = + buildList { + elapsedSec?.let { add(DateUtils.formatElapsedTime(it)) } + if (elapsedSec != null && expectedSec != null) { + add(formatTimeLeft(context, expectedSec, elapsedSec)) + } + }.joinToString(" • ") + + Text( + text = if (suffix.isEmpty()) base else "$base • $suffix", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + // once the nonce is found the job is signing/broadcasting — + // there is nothing safe to abort anymore. + if (job.isCancellable) { + IconButton( + onClick = { onCancelJob(job.id) }, + modifier = Modifier.size(22.dp), + ) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.pow_notification_cancel_all), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + } + } + + // Predictable end: the bar fills over the estimated duration for + // this difficulty. The search is memoryless, so once the mean is + // passed there is no honest remainder to show — fall back to the + // indeterminate sweep instead of a bar stuck at 100%. + if (elapsedSec != null) { + val fraction = expectedSec?.let { (elapsedSec / it).toFloat() } + + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.width(26.dp)) + if (fraction != null && fraction < 1f) { + LinearProgressIndicator( + progress = { fraction }, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } else { + LinearProgressIndicator( + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + + Spacer(Modifier.height(4.dp)) + } + } + + // nothing mining yet (all jobs waiting for a worker): keep the shared + // activity sweep so the banner still reads as "working". + if (!anyMiningStarted) { + Spacer(Modifier.height(4.dp)) + + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } +} + @Composable private fun SingleBroadcastContent(broadcast: BroadcastEvent) { Row( @@ -443,6 +633,9 @@ fun Event.toKindName(): String = else -> stringRes(R.string.post) } +@Composable +fun kindToName(kind: Int): String = stringRes(powKindLabelRes(kind)) + @Preview @Composable fun BroadcastBannerSingleEventPreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt index 2a6bb6c98d..2715778e18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/DisplayBroadcastProgress.kt @@ -37,11 +37,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastEvent +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay /** @@ -50,29 +53,38 @@ import kotlinx.coroutines.delay * - CompletedBroadcastIndicator: Shows completed broadcast for tap-to-view (auto-dismisses after 10s) * - BroadcastDetailsSheet: Shows detailed relay status on tap * - * Hidden when the "Tracked broadcasts" UI setting is off. + * The relay-progress part is hidden when the "Tracked broadcasts" UI setting + * is off, but the NIP-13 mining phase always shows — the user needs to see + * (and be able to cancel) posts still burning CPU in the queue. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { val useTrackedBroadcasts by accountViewModel.settings.uiSettingsFlow.useTrackedBroadcasts .collectAsStateWithLifecycle() - if (useTrackedBroadcasts != BooleanType.ALWAYS) return + val trackingEnabled = useTrackedBroadcasts == BooleanType.ALWAYS - val activeBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() + val miningJobs by Amethyst.instance.powPublishQueue.jobs + .collectAsStateWithLifecycle() + val trackedBroadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() + val activeBroadcasts = if (trackingEnabled) trackedBroadcasts else persistentListOf() // State for details sheet var seeDetails by remember { mutableStateOf(false) } - if (activeBroadcasts.isEmpty() && !seeDetails) return + if (activeBroadcasts.isEmpty() && miningJobs.isEmpty() && !seeDetails) return if (!seeDetails) { - DisplaySnack(activeBroadcasts, { seeDetails = true }, accountViewModel) + DisplaySnack( + activeBroadcasts, + miningJobs, + { if (activeBroadcasts.isNotEmpty()) seeDetails = true }, + accountViewModel, + ) LaunchedEffect(activeBroadcasts) { // this effect gets restarted every time the active broadcast changes - val allComplete = activeBroadcasts.all { it.isComplete } - if (allComplete) { + if (activeBroadcasts.isNotEmpty() && activeBroadcasts.all { it.isComplete }) { // All relays responded — dismiss quickly delay(3_000) accountViewModel.broadcastTracker.clear() @@ -111,12 +123,15 @@ fun DisplayBroadcastProgress(accountViewModel: AccountViewModel) { @Composable fun DisplaySnack( activeBroadcasts: ImmutableList, + miningJobs: ImmutableList, onTap: () -> Unit, accountViewModel: AccountViewModel, ) { Box(modifier = Modifier.fillMaxSize()) { BroadcastBanner( broadcasts = activeBroadcasts, + miningJobs = miningJobs, + onCancelJob = { Amethyst.instance.powPublishQueue.cancel(it) }, onTap = onTap, onRetryAll = { activeBroadcasts.forEach { b -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt new file mode 100644 index 0000000000..22e0dda3a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt @@ -0,0 +1,198 @@ +/* + * 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.note.creators.pow + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator +import com.vitorpamplona.amethyst.service.pow.formatApproxDuration +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +val POW_PRESETS = listOf(16, 20, 24, 28) + +/** + * Composer options-row button showing the NIP-13 difficulty this post will be + * mined at: a manufacturing gear with the difficulty as a small badge when + * mining is on, a dimmed gear when off. Tapping opens a menu to + * raise/lower/disable mining for this post only — the account setting is + * untouched. + * + * [effectiveDifficulty] is what will actually be used at send time (override + * or account default); null/0 means the post publishes without PoW. + * [defaultDifficulty] is what the account settings alone would produce, shown + * in the "default" menu entry. [onSelect] receives null to follow the account + * default, 0 to disable for this post, or a positive difficulty. + */ +@Composable +fun PowOverrideButton( + effectiveDifficulty: Int?, + defaultDifficulty: Int?, + isOverridden: Boolean, + onSelect: (Int?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val isActive = effectiveDifficulty != null && effectiveDifficulty > 0 + + Box { + IconButton(onClick = { expanded = true }) { + Box( + Modifier + .height(20.dp) + .width(23.dp), + ) { + Icon( + symbol = MaterialSymbols.Manufacturing, + contentDescription = stringRes(R.string.pow_settings_title), + modifier = Modifier.size(18.dp).align(Alignment.BottomStart), + tint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + }, + ) + if (isActive) { + Text( + text = effectiveDifficulty.toString(), + fontSize = 9.sp, + lineHeight = 9.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopEnd), + ) + } + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + // benchmarked on first open (~250 ms, cached after): each option + // shows what it would cost on THIS device, e.g. "24 bits · ≈ 45 seconds". + val context = LocalContext.current + val hashRate by + produceState(initialValue = null) { + value = PoWEstimator.hashesPerSecond() + } + + fun eta(difficulty: Int): String? = hashRate?.let { formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, it)) } + + fun withEta( + label: String, + difficulty: Int, + ): String = eta(difficulty)?.let { "$label · $it" } ?: label + + DropdownMenuItem( + text = { + Text( + if (defaultDifficulty != null && defaultDifficulty > 0) { + withEta( + pluralStringResource(R.plurals.pow_option_default_on, defaultDifficulty, defaultDifficulty), + defaultDifficulty, + ) + } else { + stringRes(R.string.pow_option_default_off) + }, + fontWeight = if (!isOverridden) FontWeight.Bold else null, + ) + }, + onClick = { + onSelect(null) + expanded = false + }, + ) + DropdownMenuItem( + text = { + Text( + stringRes(R.string.pow_option_off), + fontWeight = if (isOverridden && !isActive) FontWeight.Bold else null, + ) + }, + onClick = { + onSelect(0) + expanded = false + }, + ) + POW_PRESETS.forEach { preset -> + DropdownMenuItem( + text = { + Text( + withEta(pluralStringResource(R.plurals.pow_option_bits, preset, preset), preset), + fontWeight = if (isOverridden && effectiveDifficulty == preset) FontWeight.Bold else null, + ) + }, + onClick = { + onSelect(preset) + expanded = false + }, + ) + } + } + } +} + +@Preview +@Composable +private fun PowOverrideButtonPreview() { + ThemeComparisonRow { + Row { + PowOverrideButton( + effectiveDifficulty = 24, + defaultDifficulty = 20, + isOverridden = true, + onSelect = {}, + ) + PowOverrideButton( + effectiveDifficulty = null, + defaultDifficulty = null, + isOverridden = false, + onSelect = {}, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 7599d47f54..7324d6d65d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -20,14 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import com.vitorpamplona.amethyst.ui.theme.Font14SP +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink @Composable @Preview @@ -37,13 +50,35 @@ fun DisplayPoWPreview() { ) } +/** + * Compact pill showing the proof of work a received note carries: a bolt plus + * the difficulty in leading zero bits. Sits inline in note headers, so it + * stays at text height. + */ @Composable fun DisplayPoW(pow: Int) { - Text( - "PoW-$pow", - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) { + Icon( + symbol = MaterialSymbols.Manufacturing, + contentDescription = stringRes(R.string.pow_settings_title), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(12.dp), + ) + Text( + text = pow.toString(), + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontSize = 12.sp, + lineHeight = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index d89c1f38fe..740a62cea5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -87,6 +88,7 @@ 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.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -235,6 +237,20 @@ open class CommentPostViewModel : var wantsAnonymousPost by mutableStateOf(false) + // NIP-13 per-post override from the composer chip: null = follow account + // settings, 0 = don't mine this post, >0 = mine at that difficulty. + var powOverride by mutableStateOf(null) + + fun effectivePowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(CommentEvent.KIND, powOverride) + } + + fun defaultPowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(CommentEvent.KIND) + } + // A single ephemeral signer reused for the whole compose session so that media // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed // by the same throwaway key, instead of leaking the real account's pubkey into the @@ -521,8 +537,15 @@ open class CommentPostViewModel : val draftToDelete = draftNote val anonymous = wantsAnonymousPost + // captured before cancel() resets the chip + val chosenPow = powOverride cancel() + // Draft deletion lives INSIDE each publish continuation: when the post + // is mined first, the draft must survive until the mined event is + // actually signed and dispatched — a cancelled or process-killed + // mining job would otherwise have destroyed the only copy of the text. + // A reply within a NIP-29 group is group content: pin it to the group's // host relay (the relay the thread was seen on) instead of the author's // outbox, so it reaches the group and — for a private/closed group — is @@ -541,19 +564,39 @@ open class CommentPostViewModel : } if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) + // The anonymous key signs without a client tag, so the template is + // mined as-is against the throwaway pubkey — and never checkpointed + // to disk, so the key and content can't outlive the process. + val anonSigner = anonymousSigner() + val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, chosenPow) + val enqueued = + powDifficulty != null && + accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> + // fresh created_at at mining start (NIP-13 recommendation): + // the job may have waited in the queue behind other posts. + val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) + accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } + if (!enqueued) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonSigner) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } } else if (replyGroupId != null) { // Group content: route to the resolved host. If it couldn't be resolved, publish to the // parent's relays (possibly empty) rather than broadcasting to the outbox — better to // under-deliver a group reply than to leak group participation to unrelated relays. val relays = groupHostRelays ?: replyingTo?.relays.orEmpty() - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { relays } + accountViewModel.account.sendMined(template, PoWReplay.ToRelays(relays), chosenPow) { readyTemplate -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(readyTemplate) { relays } + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } } else { - accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) - } - - accountViewModel.viewModelScope.launch(Dispatchers.IO) { - accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + accountViewModel.account.sendMined(template, PoWReplay.Broadcast(extraNotesToBroadcast), chosenPow) { readyTemplate -> + accountViewModel.account.signAndComputeBroadcast(readyTemplate, extraNotesToBroadcast) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } } } @@ -824,6 +867,7 @@ open class CommentPostViewModel : wantsSecretEmoji = false wantsAnonymousPost = false anonymousSignerCache = null + powOverride = null forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 8a4c67b999..8d4497e18d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest @@ -505,5 +506,12 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + PowOverrideButton( + effectiveDifficulty = postViewModel.effectivePowDifficulty(), + defaultDifficulty = postViewModel.defaultPowDifficulty(), + isOverridden = postViewModel.powOverride != null, + onSelect = { postViewModel.powOverride = it }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index bf63ace422..9d27b2b332 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -389,15 +389,26 @@ class AccountSessionManager( localPreferences.deleteAccount(accountInfo) accountsCache.removeAccount(hex) accountsCache.deleteAccountFiles(hex) - Amethyst.instance.scheduledPostStore.removeForAccount(hex) + purgePendingPosts(hex) loginWithDefaultAccount() } else { // delete without switching logins localPreferences.deleteAccount(accountInfo) accountsCache.removeAccount(hex) accountsCache.deleteAccountFiles(hex) - Amethyst.instance.scheduledPostStore.removeForAccount(hex) + purgePendingPosts(hex) } } } + + /** + * Drops everything a deleted account left in the publish pipelines: parked + * scheduled posts, checkpointed PoW mining jobs, and any of its jobs still + * queued or mining (a post must not publish after its account is gone). + */ + private suspend fun purgePendingPosts(hex: String) { + Amethyst.instance.scheduledPostStore.removeForAccount(hex) + Amethyst.instance.powPublishQueue.cancelForOwner(hex) + Amethyst.instance.powJobStore.removeForAccount(hex) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 23f5b595d6..1fafa63d2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -69,6 +70,8 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute +import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.V4VPaymentHandler @@ -78,6 +81,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent +import com.vitorpamplona.amethyst.service.pow.powKindLabelRes import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.ui.actions.Dao @@ -144,6 +148,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -279,6 +284,19 @@ class AccountViewModel( // receivers can reach callManager + accountViewModel. com.vitorpamplona.amethyst.service.call.CallSessionBridge .set(callManager, this) + + // A mined post that fails to sign or broadcast would otherwise die + // silently — the composer already returned when it was enqueued. + viewModelScope.launch { + Amethyst.instance.powPublishQueue.failures.collect { failure -> + val kindLabel = stringRes(Amethyst.instance.appContext, powKindLabelRes(failure.kind)) + if (failure.willRetryOnRestart) { + toastManager.toast(R.string.pow_settings_title, R.string.pow_publish_failed_retry, kindLabel) + } else { + toastManager.toast(R.string.pow_settings_title, R.string.pow_publish_failed, kindLabel, failure.message.orEmpty()) + } + } + } } /** @@ -546,7 +564,8 @@ class AccountViewModel( account.deletePrivately(privateRumors, note) } } else { - if (settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { + val minesReactions = account.powDifficultyFor(ReactionEvent.KIND) != null + if (!minesReactions && settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> broadcastTracker.trackBroadcast( @@ -558,7 +577,9 @@ class AccountViewModel( account.consumeReactionEvent(event) } } else { - // Fire-and-forget (original behavior) + // Fire-and-forget (original behavior). When PoW mining is + // on for reactions this path also routes through the + // mining queue, which has its own progress banner. account.reactTo(note, reaction) } } @@ -1246,11 +1267,17 @@ class AccountViewModel( } fun boost(note: Note) = - launchTrackedOrDirect( - createTracked = { account.createBoostEvent(note) }, - consumeTracked = account::consumeBoostEvent, - direct = { account.boost(note) }, - ) + if (account.powDifficultyFor(RepostEvent.KIND) != null) { + // Reposts are mined: route through the queue (which has its own + // progress banner) instead of the inline tracked path. + launchSigner { account.boost(note) } + } else { + launchTrackedOrDirect( + createTracked = { account.createBoostEvent(note) }, + consumeTracked = account::consumeBoostEvent, + direct = { account.boost(note) }, + ) + } fun removeEmojiPack(emojiPack: Note) = launchSigner { account.removeEmojiPack(emojiPack) } @@ -1640,6 +1667,13 @@ class AccountViewModel( fun updateAddClientTag(add: Boolean) = launchSigner { account.updateAddClientTag(add) } + fun updatePowDifficulty(difficulty: Int) = launchSigner { account.updatePowDifficulty(difficulty) } + + fun updatePowCategory( + category: PoWCategory, + enabled: Boolean, + ) = launchSigner { account.updatePowCategory(category, enabled) } + fun updateFilterSpam(filterSpam: Boolean) = launchSigner { if (account.updateFilterSpam(filterSpam)) { @@ -1927,7 +1961,7 @@ class AccountViewModel( } noteEvent is ChatroomKeyable -> { - account.markAsRead("Room/${noteEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt) + account.markAsRead(privateChatLastReadRoute(noteEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt) } noteEvent is DraftWrapEvent -> { @@ -1935,7 +1969,7 @@ class AccountViewModel( if (innerEvent is IsInPublicChatChannel) { account.markAsRead("Channel/${innerEvent.channelId()}", noteEvent.createdAt) } else if (innerEvent is ChatroomKeyable) { - account.markAsRead("Room/${innerEvent.chatroomKey(account.signer.pubKey).hashCode()}", noteEvent.createdAt) + account.markAsRead(privateChatLastReadRoute(innerEvent.chatroomKey(account.signer.pubKey)), noteEvent.createdAt) } } } @@ -1945,25 +1979,18 @@ class AccountViewModel( } } - private fun unreadPrivateChatRoute(chat: Note): Pair? { - val noteEvent = chat.event ?: return null - val room = (noteEvent as? ChatroomKeyable)?.chatroomKey(account.signer.pubKey) ?: return null - if (account.isAllHidden(room.users)) return null - return privateChatRoute(room) to noteEvent.createdAt - } + private fun unreadPrivateChatRoute(chat: Note): Pair? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden) private fun markHiddenChatroomsAsRead() { account.chatroomList.rooms.forEach { roomKey, chatroom -> if (account.isAllHidden(roomKey.users)) { chatroom.newestMessage?.createdAt()?.let { - account.markAsRead(privateChatRoute(roomKey), it) + account.markAsRead(privateChatLastReadRoute(roomKey), it) } } } } - private fun privateChatRoute(room: ChatroomKey) = "Room/${room.hashCode()}" - class Factory( val account: Account, val settings: UiSettingsState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 1a49179ec6..28ca3c027f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor @@ -96,7 +98,10 @@ class EventProcessor( is CallRenegotiateEvent, -> callManager?.onSignalingEvent(event) - is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is ChatroomKeyable -> { + chatHandler.add(event, eventNote, publicNote) + markOwnChatMessageAsRead(event) + } is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) @@ -110,6 +115,23 @@ class EventProcessor( } } + /** + * A chat message authored by this account — sent from this device through any code + * path, or arriving via the self-addressed gift wrap from another device — means the + * user was caught up with the room when they sent it, so advance the room's read + * marker here at the single ingestion choke point rather than at each send site + * (#1286, #1287). markAsRead is monotonic, so out-of-order history sync cannot move + * the marker backwards. Unsent drafts never reach this branch (DraftEventHandler + * indexes their rumors directly into the chatroom). + */ + private fun markOwnChatMessageAsRead(event: T) where T : Event, T : ChatroomKeyable { + val me = account.signer.pubKey + val room = event.chatroomKey(me) + if (chatMessageMarksRoomAsRead(event, room, me)) { + account.markAsRead(privateChatLastReadRoute(room), event.createdAt) + } + } + suspend fun delete(note: Note) { note.event?.let { event -> try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 4632448358..efdfd2c396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -268,7 +269,7 @@ fun ChatroomViewUI( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, nav = nav, - routeForLastRead = "Room/${room.hashCode()}", + routeForLastRead = privateChatLastReadRoute(room), avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index f870b0595b..fe79dde482 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -316,10 +317,14 @@ open class ChannelNewMessageViewModel : val draftToDelete = draftNote cancel() - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { - channelRelays.toList() - } - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + // Kinds 42/1311 are the PUBLIC_CHAT PoW category: route through the + // mining gate (a no-op when the category is off). Draft deletion runs + // inside the publish continuation so a cancelled mining job can't + // destroy the only copy of the text. + accountViewModel.account.sendMined(template, PoWReplay.ToRelays(channelRelays.toList())) { readyTemplate -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(readyTemplate) { + channelRelays.toList() + } accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 383bb3254a..693accc760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -62,7 +62,9 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription @@ -144,7 +146,7 @@ fun ChatroomComposeChannelOrUser( val baseNoteEvent = baseNote.event if (baseNoteEvent is DraftWrapEvent) { ObserveDraftEvent(baseNote, accountViewModel) { innerNote -> - ChatroomEntry(innerNote, accountViewModel, nav) + ChatroomEntry(innerNote, accountViewModel, nav, isDraft = true) } } else { ChatroomEntry(baseNote, accountViewModel, nav) @@ -156,6 +158,7 @@ private fun ChatroomEntry( lastMessage: Note, accountViewModel: AccountViewModel, nav: INav, + isDraft: Boolean = false, ) { if (lastMessage is RelayGroupServerRoomNote) { RelayGroupServerRoomCompose(lastMessage, accountViewModel, nav) @@ -220,7 +223,7 @@ private fun ChatroomEntry( is ChatroomKeyable -> { val room = baseNoteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex) - UserRoomCompose(room, lastMessage, accountViewModel, nav) + UserRoomCompose(room, lastMessage, isDraft, accountViewModel, nav) } is EphemeralChatEvent -> { @@ -585,6 +588,7 @@ private fun ChannelTitleWithLabelInfo( private fun UserRoomCompose( room: ChatroomKey, lastMessage: Note, + isDraft: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { @@ -636,8 +640,15 @@ private fun UserRoomCompose( } } - val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() - if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { + // A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention. + val newestEvent = lastMessage.event + val countsAsRead = + !isDraft && + newestEvent != null && + chatMessageMarksRoomAsRead(newestEvent, room, accountViewModel.account.signer.pubKey) + + val lastReadTime by accountViewModel.account.loadLastReadFlow(privateChatLastReadRoute(room)).collectAsStateWithLifecycle() + if (!countsAsRead && (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { Spacer(modifier = Height4dpModifier) NewItemsBubble() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index e7ef300063..7bc0a0f755 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -353,9 +354,28 @@ class LongFormPostViewModel : val draftToDelete = draftNote cancel() + // Draft deletion runs INSIDE the publish continuation: when the + // article is mined first, the draft must survive until the mined event + // is actually signed and dispatched — a cancelled or process-killed + // mining job would otherwise have destroyed the only copy of the text. + accountViewModel.account.sendMined(template, PoWReplay.Broadcast()) { readyTemplate -> + broadcastArticle(readyTemplate) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } + } + + /** + * The publish step shared by the direct and post-mining paths. Tracked + * broadcasting is launched fire-and-forget on the account scope — the + * composer must not wait for relay acks before navigating away, and a + * mined job must not hold its queue entry while acks trickle in. Runs on + * the mining queue's scope when PoW is on, so it must not touch + * viewModelScope. + */ + private suspend fun broadcastArticle(template: EventTemplate) { if (accountViewModel.settings.useTrackedBroadcasts()) { val (event, relays, extras) = accountViewModel.account.createPostEvent(template, emptyList()) - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.account.scope.launch { accountViewModel.broadcastTracker.trackBroadcast( event = event, relays = relays, @@ -366,10 +386,6 @@ class LongFormPostViewModel : } else { accountViewModel.account.signAndComputeBroadcast(template, emptyList()) } - - accountViewModel.launchSigner { - accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) - } } suspend fun sendDraftSync() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index c0b698c19b..534927c0f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField +import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtButton import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker @@ -833,6 +834,13 @@ private fun BottomRowActions( } } + PowOverrideButton( + effectiveDifficulty = postViewModel.effectivePowDifficulty(), + defaultDifficulty = postViewModel.defaultPowDifficulty(), + isOverridden = postViewModel.powOverride != null, + onSelect = { postViewModel.powOverride = it }, + ) + // A group thread's title is required, so the field is always shown for it — no toggle. if (postViewModel.groupThreadTarget == null) { AddSubjectButton(postViewModel.wantsSubject) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 14a0a740de..39434ccf93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -116,6 +117,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.tags.markedETags import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.prepareETagsAsReplyTo +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds @@ -137,6 +139,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.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -173,6 +176,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import java.util.UUID enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -381,6 +385,32 @@ open class ShortNotePostViewModel : // Null = post immediately on Send (existing behavior). var scheduledForSec by mutableStateOf(null) + // NIP-13 per-post override from the composer chip: null = follow account + // settings, 0 = don't mine this post, >0 = mine at that difficulty. + var powOverride by mutableStateOf(null) + + // Best guess of the kind createTemplate() will produce, for the PoW chip. + // A private note is gift-wrapped, so what gets mined (and what the + // settings gate on) is the kind-1059 wrap, not the inner kind-1. + private fun anticipatedPowKind(): Int = + when { + wantsPrivateNote -> GiftWrapEvent.KIND + wantsPoll -> PollEvent.KIND + wantsZapPoll -> ZapPollEvent.KIND + voiceRecording != null -> VoiceEvent.KIND + else -> TextNoteEvent.KIND + } + + fun effectivePowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(anticipatedPowKind(), powOverride) + } + + fun defaultPowDifficulty(): Int? { + if (!::accountViewModel.isInitialized) return null + return accountViewModel.account.powDifficultyFor(anticipatedPowKind()) + } + // AI Writing Help for testing private val useMockAi = false @@ -956,13 +986,20 @@ open class ShortNotePostViewModel : val scheduledFor = scheduledForSec val privately = wantsPrivateNote val threadTarget = groupThreadTarget + // captured before cancel() resets the chip + val chosenPow = powOverride cancel() + // Draft deletion lives INSIDE each publish continuation: when the post + // is mined first, the draft must survive until the mined event is + // actually signed and dispatched — a cancelled or process-killed + // mining job would otherwise have destroyed the only copy of the text. + if (threadTarget != null) { // NIP-29 group thread: publish only to the group's host relay, never the account's // outbox — bypass the private/scheduled/anonymous paths entirely. - accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { threadTarget.relays } - accountViewModel.launchSigner { + accountViewModel.account.sendMined(template, PoWReplay.ToRelays(threadTarget.relays), chosenPow) { readyTemplate -> + accountViewModel.account.signAndSendPrivatelyOrBroadcast(readyTemplate) { threadTarget.relays } accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } return @@ -972,19 +1009,22 @@ open class ShortNotePostViewModel : // Gift-wrap to the p-tagged users instead of publishing. Private // wins over the anonymous and scheduled modes: a locked private // reply must never fall through to a public publish path (the UI - // hides those toggles while private mode is on). + // 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. @Suppress("UNCHECKED_CAST") - accountViewModel.account.sendPrivateNote(template as EventTemplate) - accountViewModel.launchSigner { - accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) - } + accountViewModel.account.sendPrivateNote(template as EventTemplate, chosenPow) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) return } if (scheduledFor != null && !anonymous) { // Re-stamp the template with created_at = scheduled time so the post, // when published later, shows up at its scheduled moment in feeds - // rather than as N minutes/hours old (= compose time). + // rather than as N minutes/hours old (= compose time). Mining + // commits the future created_at into the hashed id, and the worker + // publishes the stored signed JSON verbatim, so the nonce is still + // valid at publish time. val rescheduledTemplate = EventTemplate( createdAt = scheduledFor, @@ -992,35 +1032,88 @@ open class ShortNotePostViewModel : tags = template.tags, content = template.content, ) - val (event, relays, extras) = accountViewModel.account.createPostEvent(rescheduledTemplate, extraNotesToBroadcast) - Amethyst.instance.scheduledPostStore.add( - ScheduledPost( - id = - java.util.UUID - .randomUUID() - .toString(), - accountPubkey = event.pubKey, - signedEventJson = event.toJson(), - relayUrls = relays.map { it.url }, - extraEventsJson = extras.map { it.toJson() }, - publishAtSec = scheduledFor, - createdAtSec = System.currentTimeMillis() / 1000, - ), - ) - accountViewModel.launchSigner { + + accountViewModel.account.sendMined( + rescheduledTemplate, + PoWReplay.Schedule(scheduledFor, extraNotesToBroadcast), + chosenPow, + ) { readyTemplate -> + storeScheduledPost(readyTemplate, extraNotesToBroadcast, scheduledFor) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } return } if (anonymous) { - accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonymousSigner()) - } else if (accountViewModel.settings.useTrackedBroadcasts()) { - // Tracked broadcasting with progress feedback (non-blocking) - val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) + // The anonymous key signs without a client tag, so the template is + // mined as-is against the throwaway pubkey — and never checkpointed + // to disk, so the key and content can't outlive the process. + val anonSigner = anonymousSigner() + val powDifficulty = accountViewModel.account.powDifficultyFor(template.kind, chosenPow) + val enqueued = + powDifficulty != null && + accountViewModel.account.mineInBackground(template.kind, powDifficulty) { isActive -> + // fresh created_at at mining start (NIP-13 recommendation): + // the job may have waited in the queue behind other posts. + val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) + val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) + accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } + if (!enqueued) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast, anonSigner) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } + return + } - // Launch broadcast in background - don't wait for completion - accountViewModel.viewModelScope.launch(Dispatchers.IO) { + accountViewModel.account.sendMined(template, PoWReplay.Broadcast(extraNotesToBroadcast), chosenPow) { readyTemplate -> + broadcastPublicPost(readyTemplate, extraNotesToBroadcast) + accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) + } + } + + /** + * Signs the (possibly mined) re-stamped template and parks it in the + * scheduled-post store for the worker to publish at its created_at time. + * Runs on the mining queue's scope when PoW is on, so it must not touch + * viewModelScope. + */ + private suspend fun storeScheduledPost( + template: EventTemplate, + extraNotesToBroadcast: List, + publishAtSec: Long, + ) { + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) + Amethyst.instance.scheduledPostStore.add( + ScheduledPost( + id = UUID.randomUUID().toString(), + accountPubkey = event.pubKey, + signedEventJson = event.toJson(), + relayUrls = relays.map { it.url }, + extraEventsJson = extras.map { it.toJson() }, + publishAtSec = publishAtSec, + createdAtSec = System.currentTimeMillis() / 1000, + ), + ) + } + + /** + * The publish step shared by the direct and post-mining paths: signs the + * template and dispatches the broadcast. Tracked broadcasting is launched + * fire-and-forget on the account scope — the composer must not wait for + * relay acks before navigating away, and a mined job must not hold its + * queue entry while acks trickle in (the event is signed and dispatched; + * only progress reporting remains). Runs on the mining queue's scope when + * PoW is on, so it must not touch viewModelScope. + */ + private suspend fun broadcastPublicPost( + template: EventTemplate, + extraNotesToBroadcast: List, + ) { + if (accountViewModel.settings.useTrackedBroadcasts()) { + val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) + accountViewModel.account.scope.launch { accountViewModel.broadcastTracker.trackBroadcast( event = event, relays = relays, @@ -1029,13 +1122,8 @@ open class ShortNotePostViewModel : accountViewModel.account.consumePostEvent(event, relays, extras) } } else { - // Fire-and-forget (original behavior) accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) } - - accountViewModel.launchSigner { - accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) - } } suspend fun sendDraftSync() { @@ -1451,6 +1539,7 @@ open class ShortNotePostViewModel : wantsAnonymousPost = false anonymousSignerCache = null scheduledForSec = null + powOverride = null wantsPrivateNote = false privateNoteLocked = false wantsToAddNotifyUser = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 40c5b3919a..5917ab4994 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.uploads.CompressorQuality @@ -251,8 +252,11 @@ class VoiceReplyViewModel : ViewModel() { // Check if replying to a voice event val voiceHint = note.toEventHint() if (voiceHint != null) { - // Create VoiceReplyEvent (KIND 1244) for voice-to-voice replies - accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, voiceHint)) + // Create VoiceReplyEvent (KIND 1244) for voice-to-voice replies, + // routed through the PoW gate (VOICE category; no-op when off) + accountViewModel.account.sendMined(VoiceReplyEvent.build(audioMeta, voiceHint), PoWReplay.Broadcast()) { + accountViewModel.account.signAndComputeBroadcast(it) + } } else { // Create TextNoteEvent (KIND 1) with audio IMeta for voice replies to regular notes val textHint = note.toEventHint() @@ -270,7 +274,9 @@ class VoiceReplyViewModel : ViewModel() { // Add audio as IMeta attachment add(audioMeta.toIMetaArray()) } - accountViewModel.account.signAndComputeBroadcast(template) + accountViewModel.account.sendMined(template, PoWReplay.Broadcast()) { + accountViewModel.account.signAndComputeBroadcast(it) + } } accountViewModel.account.settings.changeDefaultFileServer(server) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index fe5a328677..a4e71057af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -829,9 +829,9 @@ private fun RelayHeader( textAlign = TextAlign.Center, ) - Row( - modifier = Modifier.padding(horizontal = 30.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), + FlowRow( + modifier = Modifier.padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), ) { OutlinedButton( shape = ButtonBorder, @@ -843,7 +843,22 @@ private fun RelayHeader( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringRes(R.string.see_relay_feed)) + Text(text = stringRes(R.string.see_relay_feed), maxLines = 1) + } + + if (supportsNip29(relayInfo.supported_nips)) { + OutlinedButton( + onClick = { nav.nav(Route.RelayGroupServer(relay.url)) }, + shape = ButtonBorder, + ) { + Icon( + MaterialSymbols.Groups, + contentDescription = stringRes(R.string.relay_groups_button), + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringRes(R.string.relay_groups_button), maxLines = 1) + } } if (supportsNip43(relayInfo.supported_nips)) { @@ -857,7 +872,7 @@ private fun RelayHeader( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(4.dp)) - Text(text = stringRes(R.string.relay_members)) + Text(text = stringRes(R.string.relay_members), maxLines = 1) } } @@ -877,6 +892,8 @@ private fun RelayHeader( } } +fun supportsNip29(supportedNips: List?): Boolean = supportedNips?.any { it == "29" } == true + fun supportsNip43(supportedNips: List?): Boolean = supportedNips?.any { it == "43" } == true @Composable @@ -939,7 +956,7 @@ fun LimitationsCard(lim: Nip11RelayInformation.RelayInformationLimitation) { val minPoW = lim.min_pow_difficulty if (minPoW != null && minPoW > 0) { - InfoRow(MaterialSymbols.Bolt, stringRes(R.string.minimum_pow), stringRes(R.string.amount_in_bits, minPoW)) + InfoRow(MaterialSymbols.Manufacturing, stringRes(R.string.minimum_pow), stringRes(R.string.amount_in_bits, minPoW)) } else { lim.min_prefix?.let { if (it > 0) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index 33bd6417e0..e28eeb82bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -27,24 +27,35 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator +import com.vitorpamplona.amethyst.model.AccountPoWPreferences import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.service.pow.formatApproxDuration import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.creators.pow.POW_PRESETS import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -115,9 +126,151 @@ fun ComposeSettingsContent( SettingsDivider() SignatureTile(sharedPrefs.composeSignature) } + + SettingsSection(R.string.pow_settings_title) { + PowDifficultyTile(accountViewModel) + SettingsDivider() + PowCategoryChecklist(accountViewModel) + } } } +@Composable +private fun PowDifficultyTile(accountViewModel: AccountViewModel) { + val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork + .difficulty + .collectAsStateWithLifecycle() + + SettingsBlockTile( + icon = MaterialSymbols.Manufacturing, + title = stringRes(R.string.pow_difficulty_title), + description = stringRes(R.string.pow_difficulty_explainer), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = difficulty <= 0, + onClick = { accountViewModel.updatePowDifficulty(0) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = POW_PRESETS.size + 1), + ) { + Text(stringRes(R.string.pow_difficulty_off)) + } + POW_PRESETS.forEachIndexed { index, preset -> + SegmentedButton( + selected = difficulty == preset, + onClick = { accountViewModel.updatePowDifficulty(preset) }, + shape = SegmentedButtonDefaults.itemShape(index = index + 1, count = POW_PRESETS.size + 1), + ) { + Text(preset.toString()) + } + } + } + + PowTimeEstimate(difficulty) + } + + SettingsSubControlRow( + title = stringRes(R.string.pow_custom_difficulty_title), + description = stringRes(R.string.pow_custom_difficulty_explainer), + ) { + SettingsStepper( + value = difficulty, + min = 0, + max = AccountPoWPreferences.MAX_POW_DIFFICULTY, + unsetLabel = stringRes(R.string.pow_difficulty_off), + onValueChange = accountViewModel::updatePowDifficulty, + ) + } +} + +/** + * "≈ 45 s per post on this device" — turns the abstract bit count into a cost + * the user can feel. The hash rate is benchmarked once (~250 ms on a worker + * thread) and cached; the figure is a statistical mean, so any single post can + * be luckier or unluckier. + */ +@Composable +private fun PowTimeEstimate(difficulty: Int) { + if (difficulty <= 0) return + + val context = LocalContext.current + val estimate by + produceState(initialValue = null, difficulty) { + val rate = PoWEstimator.hashesPerSecond() + value = formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, rate)) + } + + estimate?.let { + Text( + text = stringRes(R.string.pow_difficulty_estimate, it), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun PowCategoryChecklist(accountViewModel: AccountViewModel) { + val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork + .difficulty + .collectAsStateWithLifecycle() + val enabledCategories by accountViewModel.account.settings.syncedSettings.proofOfWork + .enabledCategories + .collectAsStateWithLifecycle() + + val miningOn = difficulty > 0 + + SettingsControlRow( + icon = MaterialSymbols.Checklist, + title = stringRes(R.string.pow_categories_title), + description = stringRes(R.string.pow_categories_explainer), + ) {} + + PoWCategory.entries.forEach { category -> + val checked = category in enabledCategories + SettingsSubControlRow( + title = stringRes(category.titleRes()), + description = stringRes(category.descriptionRes()), + enabled = miningOn, + ) { + Switch( + checked = checked, + enabled = miningOn, + onCheckedChange = { accountViewModel.updatePowCategory(category, it) }, + ) + } + } +} + +@StringRes +private fun PoWCategory.titleRes(): Int = + when (this) { + PoWCategory.SHORT_NOTES -> R.string.pow_category_short_notes + PoWCategory.COMMENTS -> R.string.pow_category_comments + PoWCategory.REPORTS -> R.string.pow_category_reports + PoWCategory.LONG_FORM -> R.string.pow_category_long_form + PoWCategory.VOICE -> R.string.pow_category_voice + PoWCategory.REPOSTS -> R.string.pow_category_reposts + PoWCategory.REACTIONS -> R.string.pow_category_reactions + PoWCategory.PUBLIC_CHAT -> R.string.pow_category_public_chat + PoWCategory.GIFT_WRAPS -> R.string.pow_category_gift_wraps + PoWCategory.OTHER_PUBLIC -> R.string.pow_category_other_public + } + +@StringRes +private fun PoWCategory.descriptionRes(): Int = + when (this) { + PoWCategory.SHORT_NOTES -> R.string.pow_category_short_notes_explainer + PoWCategory.COMMENTS -> R.string.pow_category_comments_explainer + PoWCategory.REPORTS -> R.string.pow_category_reports_explainer + PoWCategory.LONG_FORM -> R.string.pow_category_long_form_explainer + PoWCategory.VOICE -> R.string.pow_category_voice_explainer + PoWCategory.REPOSTS -> R.string.pow_category_reposts_explainer + PoWCategory.REACTIONS -> R.string.pow_category_reactions_explainer + PoWCategory.PUBLIC_CHAT -> R.string.pow_category_public_chat_explainer + PoWCategory.GIFT_WRAPS -> R.string.pow_category_gift_wraps_explainer + PoWCategory.OTHER_PUBLIC -> R.string.pow_category_other_public_explainer + } + @Composable private fun SignatureTile(flow: MutableStateFlow) { val value by flow.collectAsState() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 38613b8987..edd323241d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1855,7 +1855,7 @@ opentimestamps, timestamp, ots, proof namecoin, dns, identity, name calendar, events, reminders, rsvp - draft, posting, editor, auto-save, signature + draft, posting, editor, auto-save, signature, proof of work, pow, mining, nip-13 emoji, reactions, like navigation, tabs, nav bar tabs, feeds, threads, conversations @@ -2009,6 +2009,7 @@ Collapse each relay\'s groups into a single row, placed at its newest message. NIP-29 group display Relay groups + Groups Messages messages, chats, groups, nip-29, relay, inline, dm Create a group @@ -3808,6 +3809,84 @@ Signature Added at the end of the message when opening a new post, reply, quote, or article. Leave empty to disable. Your signature + Proof of Work + Difficulty + Mines a NIP-13 proof of work into your posts before publishing so relays and readers can weigh them against spam. Higher values take exponentially longer to mine. Posts publish in the background once mined. + Off + Custom difficulty + Fine-tune the target in leading zero bits. + What to mine + Time-critical events (relay auth, zap requests, wallet and signer messages, drafts, lists) are never mined. + Short notes & replies + The primary public spam surface + Comments + Replies to articles, files and other content + Reports + Relays weigh reports by their cost + Long-form & highlights + Articles and highlights; infrequent, cost is negligible + Voice messages + Public voice posts and replies + Reposts + High volume for active users + Reactions + Highest-volume kind; mining every like costs battery + Public & live chat + Mining delays hurt conversation flow + Private message wraps + Lets DM relays filter inbox spam; only the outer wrap is mined, never your message + Other public content + Polls, live statuses, classifieds and everything else public + Mining proof of work + + Mining proof of work… (%1$d post in queue) + Mining proof of work… (%1$d posts in queue) + + + %1$s • mining at %2$d bit + %1$s • mining at %2$d bits + + + %1$s • waiting to mine at %2$d bit + %1$s • waiting to mine at %2$d bits + + + Default (%1$d bit) + Default (%1$d bits) + + Default (off) + Off for this post + + %1$d bit + %1$d bits + + Proof of work mining + Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app. + Cancel + ≈ %1$s per post on this device + <1 s + + %1$d second + %1$d seconds + + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + ≈ %1$s left + any moment now + Your %1$s finished mining but could not be published: %2$s + Your %1$s finished mining but could not be published. It will be retried the next time the app starts. + Chat message + Report Use This Dismiss Correct diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadStateTest.kt new file mode 100644 index 0000000000..2227e72a70 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/PrivateChatroomReadStateTest.kt @@ -0,0 +1,115 @@ +/* + * 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.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The unread predicate behind the Messages tab dot and the room-row bubble: a room whose + * newest message was authored by the logged-in user counts as read (#1286, #1287), except + * notes-to-self rooms, where the user's own messages are the content still to be seen. + */ +class PrivateChatroomReadStateTest { + private val me: HexKey = "a".repeat(64) + private val peer: HexKey = "b".repeat(64) + + private val roomWithPeer = ChatroomKey(persistentSetOf(peer)) + private val selfRoom = ChatroomKey(persistentSetOf(me)) + + private fun message( + from: HexKey, + to: HexKey, + createdAt: Long, + ) = ChatMessageEvent( + id = "0".repeat(64), + pubKey = from, + createdAt = createdAt, + tags = arrayOf(arrayOf("p", to)), + content = "hello", + sig = "", + ) + + @Test + fun newestMessageFromPeerReturnsTheRoomRoute() { + val route = unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { false }) + + assertEquals(privateChatLastReadRoute(roomWithPeer) to 100L, route) + } + + @Test + fun newestMessageAuthoredByMeCountsAsRead() { + assertNull(unreadPrivateChatRoute(message(from = me, to = peer, createdAt = 100), me, isAllHidden = { false })) + } + + @Test + fun notesToSelfRoomsCanStillBeUnread() { + val route = unreadPrivateChatRoute(message(from = me, to = me, createdAt = 100), me, isAllHidden = { false }) + + assertEquals(privateChatLastReadRoute(selfRoom) to 100L, route) + } + + @Test + fun hiddenRoomsAreNeverUnread() { + assertNull(unreadPrivateChatRoute(message(from = peer, to = me, createdAt = 100), me, isAllHidden = { true })) + } + + @Test + fun missingEventIsNotUnread() { + assertNull(unreadPrivateChatRoute(null, me, isAllHidden = { false })) + } + + @Test + fun nonChatEventsAreNotUnread() { + val reaction = + ReactionEvent( + id = "0".repeat(64), + pubKey = peer, + createdAt = 100, + tags = arrayOf(arrayOf("p", me)), + content = "+", + sig = "", + ) + + assertNull(unreadPrivateChatRoute(reaction, me, isAllHidden = { false })) + } + + @Test + fun myMessageMarksAPeerRoomAsRead() { + assertEquals(true, chatMessageMarksRoomAsRead(message(from = me, to = peer, createdAt = 100), roomWithPeer, me)) + } + + @Test + fun aPeerMessageNeverMarksTheRoomAsRead() { + assertEquals(false, chatMessageMarksRoomAsRead(message(from = peer, to = me, createdAt = 100), roomWithPeer, me)) + } + + @Test + fun myMessageDoesNotMarkTheSelfRoomAsRead() { + assertEquals(false, chatMessageMarksRoomAsRead(message(from = me, to = me, createdAt = 100), selfRoom, me)) + } +} diff --git a/cli/README.md b/cli/README.md index 7b9628ac97..0ded3d65d8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -215,6 +215,9 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. | | `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. | | `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | +| `amy pow check EVENT-JSON\|-` | NIP-13 difficulty of a signed event: `actual_bits`, `committed_target`, `has_commitment`, and `effective_pow` (capped at the commitment so lucky low-target spam doesn't over-count), plus `valid` (id + signature). | +| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Exit 124 on timeout. | +| `amy pow bench` | Benchmark this machine's hash rate and print expected mining time at 16/20/24/28 bits. | | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | | `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. | @@ -381,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | Command | What it does | |---|---| -| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. | +| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). | | `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). | | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 56f2bb0514..d0fc574d7c 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -51,6 +51,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Marmot message send / list | ✅ | `commons/marmot/` | | `await` polling (KP / group / member / admin / message / rename / epoch) | ✅ | `AwaitCommands` | | NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. | +| NIP-13 proof of work (`amy notes post --pow N`, `amy pow check/mine/bench`) | ✅ | `PostCommand` + `PowCommands` — mines pre-signature via quartz `PoWMiner`; `pow mine --pubkey` covers delegated PoW; `pow check` applies the commitment cap. | | NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. | | NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. | | NIP-09 event deletion | 🆕 | Builder exists in quartz. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 8a45802e3c..0171bac900 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.cli.commands.OfferCommands import com.vitorpamplona.amethyst.cli.commands.OutboxCommand import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands import com.vitorpamplona.amethyst.cli.commands.PodcastCommands +import com.vitorpamplona.amethyst.cli.commands.PowCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands @@ -263,6 +264,7 @@ private suspend fun dispatch(argv: Array): Int { "dm" -> DmCommands.dispatch(dataDir, tail) "profile" -> ProfileCommands.dispatch(dataDir, tail) "notes" -> NotesCommands.dispatch(dataDir, tail) + "pow" -> PowCommands.dispatch(dataDir, tail) "nsite" -> NsiteCommands.dispatch(dataDir, tail) "napplet" -> NappletCommands.dispatch(dataDir, tail) "store" -> StoreCommands.dispatch(dataDir, tail) @@ -430,6 +432,13 @@ private fun printUsage() { | encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] | verify [EVENT-JSON] check an event's id hash + signature | (reads stdin when the arg is omitted or `-`) + | pow check EVENT-JSON|- NIP-13: leading-zero bits, committed target, + | effective PoW (capped at the commitment) + | pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON|- + | mine an UNSIGNED template (delegated PoW: + | ids don't commit to sigs, so amy can mine + | for any pubkey); exit 124 on timeout + | pow bench hash rate + expected seconds at 16/20/24/28 bits | key generate mint a fresh keypair (nsec + npub + hex) | key public NSEC|HEX derive the public key from a secret key | key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1… @@ -495,6 +504,8 @@ private fun printUsage() { | |Notes (NIP-10 kind:1): | notes post TEXT [--relay URL] publish a kind:1 short text note + | [--pow BITS [--pow-timeout SECS]] mine a NIP-13 proof of work first + | (exit 124 on timeout, nothing published) | (--relay accepts comma-separated extras) | notes feed [--author USER] fetch kind:1 notes | [--following] (default: own; --author: one user; diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt index 3a46c37bf4..b94ead611d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -24,22 +24,33 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.pow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException /** - * `amy post [--relay URL …]` — publish a NIP-10 kind:1 short text note - * to the user's outbox relays. + * `amy post [--relay URL …] [--pow BITS [--pow-timeout SECS]]` — + * publish a NIP-10 kind:1 short text note to the user's outbox relays, + * optionally mining a NIP-13 proof of work into it first. Mining blocks the + * invocation (the CLI process IS the job); `--pow-timeout` aborts with exit + * 124 and publishes nothing. * * Threading is intentionally out of scope here — `amy post` only handles new * top-level notes. Replies/quotes need richer event-hint plumbing and will get * their own verb when needed. */ object PostCommand { + private const val MAX_DIFFICULTY = 64 + suspend fun run( dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Output.error("bad_args", "post [--relay URL …]") + if (rest.isEmpty()) return Output.error("bad_args", "post [--relay URL …] [--pow BITS [--pow-timeout SECS]]") val text = rest[0] if (text.isBlank()) return Output.error("bad_args", "post text must not be blank") @@ -50,20 +61,48 @@ object PostCommand { ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() + val powTarget = args.flags["pow"]?.toIntOrNull() + if (args.flags.containsKey("pow") && (powTarget == null || powTarget < 1 || powTarget > MAX_DIFFICULTY)) { + return Output.error("bad_args", "--pow must be between 1 and $MAX_DIFFICULTY leading zero bits") + } + val powTimeoutSec = args.flags["pow-timeout"]?.toLongOrNull() + Context.open(dataDir).use { ctx -> ctx.prepare() val outbox = ctx.outboxRelays() - val extraNormalized = - extraRelays.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer - .normalizeOrNull(it) - } + val extraNormalized = extraRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } val targets = (outbox + extraNormalized).toSet() if (targets.isEmpty()) { return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") } - val signed = ctx.signer.sign(TextNoteEvent.build(text)) + val template = TextNoteEvent.build(text) + + var powMillis: Long? = null + val readyToSign = + if (powTarget != null) { + System.err.println("mining $powTarget bits…") + val deadlineNanos = powTimeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } + val startedAt = System.nanoTime() + val mined = + try { + withContext(Dispatchers.Default) { + PoWMiner.run(template, ctx.signer.pubKey, powTarget) { + deadlineNanos == null || System.nanoTime() < deadlineNanos + } + } + } catch (e: CancellationException) { + Output.error("pow_timeout", "did not reach $powTarget bits within ${powTimeoutSec}s; nothing was published") + return 124 + } + powMillis = (System.nanoTime() - startedAt) / 1_000_000 + System.err.println("mined in ${powMillis}ms") + mined + } else { + template + } + + val signed = ctx.signer.sign(readyToSign) val ack = ctx.publish(signed, targets) Output.emit( @@ -72,6 +111,9 @@ object PostCommand { "kind" to signed.kind, "created_at" to signed.createdAt, "content" to signed.content, + "pow" to if (powTarget != null) signed.pow() else null, + "pow_target" to powTarget, + "pow_millis" to powMillis, "published_to" to ack.filterValues { it }.keys.map { it.url }, "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt new file mode 100644 index 0000000000..bc6a1e73bb --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt @@ -0,0 +1,205 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip13Pow.commitedPoW +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.pow +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.roundToLong + +/** + * `amy pow ` — NIP-13 proof-of-work primitives. + * + * `check` and `bench` are stateless (no account, no network). `mine` works on + * an UNSIGNED template: because the NIP-01 id does not commit to the + * signature, amy can mine on behalf of any pubkey (NIP-13's delegated PoW) — + * pass `--pubkey`, or omit it to mine for the active account. + */ +object PowCommands { + // Deliberately above PoWPolicy.MAX_DIFFICULTY (the app's UI ceiling, 40): + // amy is a power tool that may mine on beefy hardware or run deliberate + // long jobs. Still bounded well under the miner's hard 256-bit limit. + private const val MAX_DIFFICULTY = 64 + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "pow", + tail, + "pow …", + mapOf( + "check" to { rest -> check(rest) }, + "mine" to { rest -> mine(dataDir, rest) }, + "bench" to { rest -> bench() }, + ), + ) + + /** + * `amy pow check ` — difficulty of a SIGNED event, with the + * NIP-13 commitment rule applied: `effective_pow` is capped at the committed + * target and `valid` covers id+signature (a forged id can claim any PoW). + */ + private fun check(rest: Array): Int { + val json = readPayload(rest) ?: return Output.error("bad_args", "pow check (- reads stdin)") + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_event", e.message) + } + + val commitment = event.tags.commitedPoW() + Output.emit( + mapOf( + "event_id" to event.id, + "valid" to event.verify(), + "actual_bits" to PoWRankEvaluator.calculatePowRankOf(event.id), + "committed_target" to commitment, + "has_commitment" to (commitment != null), + "effective_pow" to event.pow(), + ), + ) + return 0 + } + + /** + * `amy pow mine --target N [--pubkey HEX] [--timeout SECS] ` + * — mines an unsigned template and prints it back with the nonce tag, ready + * to be signed by whoever owns the pubkey. + */ + private suspend fun mine( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] " + + val target = args.flags["target"]?.toIntOrNull() ?: return Output.error("bad_args", usage) + if (target < 1 || target > MAX_DIFFICULTY) { + return Output.error("bad_args", "--target must be between 1 and $MAX_DIFFICULTY") + } + + val json = readPayload(args.positional.toTypedArray()) ?: return Output.error("bad_args", usage) + val template = + try { + EventTemplate.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_template", e.message) + } + + // normalized to lowercase: the pubkey is serialized verbatim into the + // id preimage, and NIP-01 ids/keys are lowercase hex — an uppercase + // pubkey would mine an id that never matches the signed event. + val pubKey = + ( + args.flags["pubkey"] + ?: try { + Context.open(dataDir).use { it.signer.pubKey } + } catch (e: Exception) { + return Output.error("bad_args", "no account available; pass --pubkey (${e.message})") + } + ).lowercase() + if (pubKey.length != 64 || !Hex.isHex(pubKey)) { + return Output.error("bad_args", "--pubkey must be 64 hex characters") + } + + val timeoutSec = args.flags["timeout"]?.toLongOrNull() + val deadlineNanos = timeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } + + System.err.println("mining $target bits for ${pubKey.take(8)}…") + val startedAt = System.nanoTime() + + val mined = + try { + withContext(Dispatchers.Default) { + PoWMiner.run(template, pubKey, target) { + deadlineNanos == null || System.nanoTime() < deadlineNanos + } + } + } catch (e: CancellationException) { + Output.error("pow_timeout", "did not reach $target bits within ${timeoutSec}s") + return 124 + } + + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + val id = + sha256( + EventHasherSerializer.fastMakeJsonForId( + pubKey = pubKey, + createdAt = mined.createdAt, + kind = mined.kind, + tags = mined.tags, + content = mined.content, + ), + ).toHexKey() + + Output.emit( + mapOf( + "id" to id, + "pubkey" to pubKey, + "pow" to PoWRankEvaluator.calculatePowRankOf(id), + "pow_target" to target, + "pow_millis" to elapsedMs, + "template_json" to mined.toJson(), + ), + ) + return 0 + } + + /** `amy pow bench` — hash rate + expected mining time per common target. */ + private suspend fun bench(): Int { + val rate = PoWEstimator.hashesPerSecond() + Output.emit( + mapOf( + "hashes_per_second" to rate.roundToLong(), + "expected_seconds" to + listOf(16, 20, 24, 28).associate { bits -> + bits.toString() to PoWEstimator.estimateSeconds(bits, rate) + }, + ), + ) + return 0 + } + + private fun readPayload(rest: Array): String? { + val arg = rest.firstOrNull() ?: return null + val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg + return payload.trim().ifEmpty { null } + } +} diff --git a/cli/tests/pow/.gitignore b/cli/tests/pow/.gitignore new file mode 100644 index 0000000000..3bf212ad88 --- /dev/null +++ b/cli/tests/pow/.gitignore @@ -0,0 +1 @@ +state-pow-headless/ diff --git a/cli/tests/pow/pow-headless.sh b/cli/tests/pow/pow-headless.sh new file mode 100755 index 0000000000..f4d2e629a7 --- /dev/null +++ b/cli/tests/pow/pow-headless.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# pow-headless.sh — verifies amy's NIP-13 primitives without a relay. +# +# One throwaway amy identity in an isolated $HOME. We assert that: +# +# 1. `amy pow bench` reports a positive hash rate and estimates. +# 2. `amy pow mine --target 10 --pubkey HEX