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