feat: polish the PoW surfaces — icon chip, animated banner, time estimate, note pill

- Composer chip: replaced the text button with a bolt icon carrying the
  difficulty as a small badge (primary-tinted when active, dimmed when
  off), matching the layered-icon language of the rest of the options
  row; the current choice is bolded in the override menu.
- Mining banner: pulsing bolt while the nonce search runs, per-job
  elapsed time driven by a 1 Hz clock ("Note • mining at 20 bits • 12s"),
  and proper close IconButtons instead of a text "×". PoWJobState now
  carries miningStartedAt for the elapsed display.
- Settings: a live "≈ 45s per post on this device" estimate under the
  difficulty picker, from a one-shot cached sha256 benchmark
  (PoWEstimator in commons) and the 2^d expected-attempts mean.
- Received notes: DisplayPoW's plain "PoW-24" text is now a compact
  secondaryContainer pill (bolt + bits) used by NoteCompose, thread view
  and chat messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
This commit is contained in:
Claude
2026-07-10 21:22:27 +00:00
parent 35647d0a5c
commit 6ef1caef77
7 changed files with 305 additions and 50 deletions
@@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.ui.broadcast
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,7 +49,11 @@ 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -73,6 +81,7 @@ 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
/**
@@ -149,6 +158,29 @@ private fun MiningContent(
miningJobs: ImmutableList<PoWJobState>,
onCancelJob: (String) -> Unit,
) {
// one shared pulse for the bolt — mining has no measurable progress, so
// the animation is what says "the app is working right now".
val pulse = rememberInfiniteTransition(label = "miningPulse")
val boltAlpha by pulse.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(700),
repeatMode = RepeatMode.Reverse,
),
label = "boltAlpha",
)
// 1 Hz clock driving the per-job elapsed labels
var nowSec by remember { mutableLongStateOf(TimeUtils.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(1_000)
nowSec = TimeUtils.now()
}
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -158,7 +190,7 @@ private fun MiningContent(
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = stringRes(R.string.pow_mining_title),
tint = MaterialTheme.colorScheme.primary,
tint = MaterialTheme.colorScheme.primary.copy(alpha = boltAlpha),
modifier = Modifier.size(18.dp),
)
@@ -180,13 +212,16 @@ private fun MiningContent(
) {
Spacer(Modifier.width(26.dp))
val base =
stringRes(
if (job.isMining) R.string.pow_mining_job else R.string.pow_queued_job,
kindToName(job.kind),
job.difficulty.toString(),
)
val elapsed = job.miningStartedAt?.let { formatElapsed((nowSec - it).coerceAtLeast(0)) }
Text(
text =
stringRes(
if (job.isMining) R.string.pow_mining_job else R.string.pow_queued_job,
kindToName(job.kind),
job.difficulty.toString(),
),
text = if (elapsed != null) "$base$elapsed" else base,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -194,15 +229,17 @@ private fun MiningContent(
modifier = Modifier.weight(1f),
)
Text(
text = "×",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clickable(onClick = { onCancelJob(job.id) })
.padding(start = 2.dp),
)
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),
)
}
}
}
@@ -216,6 +253,13 @@ private fun MiningContent(
}
}
private fun formatElapsed(seconds: Long): String =
if (seconds < 60) {
"${seconds}s"
} else {
"${seconds / 60}m ${seconds % 60}s"
}
@Composable
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
Row(
@@ -21,27 +21,39 @@
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
val POW_PRESETS = listOf(16, 20, 24, 28)
/**
* Composer chip showing the NIP-13 difficulty this post will be mined at.
* Tapping opens a menu to raise/lower/disable mining for this post only —
* the account setting is untouched.
* Composer options-row button showing the NIP-13 difficulty this post will be
* mined at: a bolt with the difficulty as a small badge when mining is on,
* a dimmed bolt 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.
@@ -57,25 +69,37 @@ fun PowOverrideButton(
onSelect: (Int?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val isActive = effectiveDifficulty != null && effectiveDifficulty > 0
Box {
TextButton(onClick = { expanded = true }) {
Text(
text =
if (effectiveDifficulty != null && effectiveDifficulty > 0) {
stringRes(R.string.pow_chip_active, effectiveDifficulty)
} else {
stringRes(R.string.pow_chip_off)
},
fontSize = Font14SP,
fontWeight = FontWeight.Bold,
color =
if (isOverridden || (effectiveDifficulty != null && effectiveDifficulty > 0)) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
)
IconButton(onClick = { expanded = true }) {
Box(
Modifier
.height(20.dp)
.width(23.dp),
) {
Icon(
symbol = MaterialSymbols.Bolt,
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(
@@ -90,6 +114,7 @@ fun PowOverrideButton(
} else {
stringRes(R.string.pow_option_default_off)
},
fontWeight = if (!isOverridden) FontWeight.Bold else null,
)
},
onClick = {
@@ -98,7 +123,12 @@ fun PowOverrideButton(
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.pow_option_off)) },
text = {
Text(
stringRes(R.string.pow_option_off),
fontWeight = if (isOverridden && !isActive) FontWeight.Bold else null,
)
},
onClick = {
onSelect(0)
expanded = false
@@ -106,7 +136,12 @@ fun PowOverrideButton(
)
POW_PRESETS.forEach { preset ->
DropdownMenuItem(
text = { Text(stringRes(R.string.pow_option_bits, preset)) },
text = {
Text(
stringRes(R.string.pow_option_bits, preset),
fontWeight = if (isOverridden && effectiveDifficulty == preset) FontWeight.Bold else null,
)
},
onClick = {
onSelect(preset)
expanded = false
@@ -116,3 +151,24 @@ fun PowOverrideButton(
}
}
}
@Preview
@Composable
private fun PowOverrideButtonPreview() {
ThemeComparisonRow {
Row {
PowOverrideButton(
effectiveDifficulty = 24,
defaultDifficulty = 20,
isOverridden = true,
onSelect = {},
)
PowOverrideButton(
effectiveDifficulty = null,
defaultDifficulty = null,
isOverridden = false,
onSelect = {},
)
}
}
}
@@ -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.Bolt,
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,
)
}
}
@@ -27,6 +27,7 @@ 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
@@ -37,6 +38,7 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -45,6 +47,7 @@ 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
@@ -56,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.math.roundToLong
@Composable
fun ComposeSettingsScreen(
@@ -159,6 +163,8 @@ private fun PowDifficultyTile(accountViewModel: AccountViewModel) {
}
}
}
PowTimeEstimate(difficulty)
}
SettingsSubControlRow(
@@ -175,6 +181,40 @@ private fun PowDifficultyTile(accountViewModel: AccountViewModel) {
}
}
/**
* "≈ 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 estimate by
produceState<String?>(initialValue = null, difficulty) {
val rate = PoWEstimator.hashesPerSecond()
value = formatEstimate(PoWEstimator.estimateSeconds(difficulty, rate))
}
estimate?.let {
Text(
text = stringRes(R.string.pow_difficulty_estimate, it),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
private fun formatEstimate(seconds: Double): String =
when {
seconds < 1.0 -> "<1s"
seconds < 90.0 -> "${seconds.roundToLong()}s"
seconds < 90.0 * 60.0 -> "${(seconds / 60.0).roundToLong()}m"
seconds < 48.0 * 3600.0 -> "${(seconds / 3600.0).roundToLong()}h"
else -> "${(seconds / 86400.0).roundToLong()}d"
}
@Composable
private fun PowCategoryChecklist(accountViewModel: AccountViewModel) {
val difficulty by accountViewModel.account.settings.syncedSettings.proofOfWork
+1
View File
@@ -3776,6 +3776,7 @@
<string name="pow_notification_channel_name">Proof of work mining</string>
<string name="pow_notification_channel_description">Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app.</string>
<string name="pow_notification_cancel_all">Cancel</string>
<string name="pow_difficulty_estimate">≈ %1$s per post on this device</string>
<string name="ai_writing_use_this">Use This</string>
<string name="ai_writing_dismiss">Dismiss</string>
<string name="ai_tone_correct">Correct</string>
@@ -0,0 +1,75 @@
/*
* 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.commons.service.pow
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.pow
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
import kotlin.time.TimeSource
/**
* Rough on-device estimate of how long mining a given NIP-13 difficulty takes,
* so the settings picker can say "≈ 45 s per post" instead of leaving the user
* to guess what "24 bits" means for their phone.
*
* The benchmark hashes a short-note-sized payload for ~a quarter second on the
* first call and caches the rate. Expected attempts for `d` leading zero bits
* are `2^d` (geometric mean), so the estimate is `2^d / rate` — right on
* average, but any individual post can be much luckier or unluckier.
*/
object PoWEstimator {
// representative serialized-event size for a short note; the miner hashes
// the full JSON on every attempt, so payload size sets the rate.
private const val PAYLOAD_BYTES = 300
private const val BATCH = 2_000
private val BENCH_DURATION = 250.milliseconds
private var cachedRate: Double? = null
suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double =
cachedRate ?: withContext(dispatcher) {
cachedRate ?: benchmark().also { cachedRate = it }
}
fun estimateSeconds(
difficulty: Int,
hashesPerSecond: Double,
): Double = 2.0.pow(difficulty) / hashesPerSecond.coerceAtLeast(1.0)
private fun benchmark(): Double {
val payload = ByteArray(PAYLOAD_BYTES) { (it % 251).toByte() }
// warm up JIT/caches so the measured window reflects steady state
repeat(3 * BATCH) { sha256(payload) }
val mark = TimeSource.Monotonic.markNow()
var count = 0L
while (mark.elapsedNow() < BENCH_DURATION) {
repeat(BATCH) { sha256(payload) }
count += BATCH
}
return count / mark.elapsedNow().toDouble(DurationUnit.SECONDS)
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentListOf
@@ -50,6 +51,8 @@ import kotlin.coroutines.cancellation.CancellationException
/**
* Snapshot of one queued/mining publish job, for display in the broadcast banner.
* [miningStartedAt] (epoch seconds) is set when a worker picks the job up, so
* the UI can show how long the current nonce search has been running.
*/
@Immutable
data class PoWJobState(
@@ -57,6 +60,7 @@ data class PoWJobState(
val kind: Int,
val difficulty: Int,
val isMining: Boolean,
val miningStartedAt: Long? = null,
)
/**
@@ -222,7 +226,7 @@ class PoWPublishQueue(
private fun markMining(jobId: String) {
_jobs.update { list ->
list.map { if (it.id == jobId) it.copy(isMining = true) else it }.toImmutableList()
list.map { if (it.id == jobId) it.copy(isMining = true, miningStartedAt = TimeUtils.now()) else it }.toImmutableList()
}
}