mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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(
|
||||
|
||||
+80
-24
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user