Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ

# Conflicts:
#	commons/src/commonMain/composeResources/values/strings.xml
This commit is contained in:
Claude
2026-06-08 18:15:56 +00:00
90 changed files with 6017 additions and 244 deletions
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
@@ -651,6 +652,12 @@ class Account(
}
}
suspend fun changeAudioVisualizer(style: VisualizerStyle) {
if (settings.changeAudioVisualizer(style)) {
sendNewAppSpecificData()
}
}
suspend fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
@@ -325,6 +326,15 @@ class AccountSettings(
return false
}
fun changeAudioVisualizer(style: VisualizerStyle): Boolean {
if (syncedSettings.media.audioVisualizer.value != style) {
syncedSettings.media.audioVisualizer.tryEmit(style)
saveAccountSettings()
return true
}
return false
}
fun defaultNwcWallet(): NwcWalletEntryNorm? {
val id = defaultNwcWalletId.value
val wallets = nwcWallets.value
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
@@ -62,6 +63,10 @@ class AccountSyncedSettings(
AccountVideoPlayerPreferences(
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
)
val media =
AccountMediaPreferences(
MutableStateFlow(VisualizerStyle.fromName(internalSettings.media.audioVisualizer)),
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
@@ -92,6 +97,7 @@ class AccountSyncedSettings(
security.addClientTag.value,
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -160,6 +166,11 @@ class AccountSyncedSettings(
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
}
val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer)
if (media.audioVisualizer.value != newAudioVisualizer) {
media.audioVisualizer.tryEmit(newAudioVisualizer)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -253,6 +264,11 @@ class AccountLanguagePreferences(
): String? = languagePreferences.value["$source,$target"]
}
@Stable
class AccountMediaPreferences(
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
)
@Stable
class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
@@ -155,6 +155,7 @@ class AccountSyncedSettingsInternal(
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
)
@Serializable
@@ -197,3 +198,9 @@ class AccountSecurityPreferencesInternal(
var sendKind0EventsToLocalRelay: Boolean = false,
var addClientTag: Boolean = true,
)
@Serializable
class AccountMediaPreferencesInternal(
// Stored as VisualizerStyle.name; defaults to CLASSIC (the app's classic audio animation).
var audioVisualizer: String = "CLASSIC",
)
@@ -47,12 +47,14 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -3366,6 +3368,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is FundraiserEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is BirdexEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -38,6 +39,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
@@ -180,10 +182,13 @@ fun RenderVideoPlayer(
modifier = Modifier.align(Alignment.Center),
)
val visualizerStyle by accountViewModel.audioVisualizerFlow().collectAsStateWithLifecycle()
AudioPlayingAnimation(
controllerState,
mediaItem.src.waveformData,
Modifier.fillMaxSize().align(Alignment.Center),
controllerState = controllerState,
waveform = mediaItem.src.waveformData,
mediaId = mediaItem.src.videoUri,
style = visualizerStyle,
modifier = Modifier.fillMaxSize().align(Alignment.Center),
hasBlurhash = hasBlurhash,
)
@@ -20,30 +20,61 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable.wavefront
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.delayedByFrames
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.playerPool.PcmTapRegistry
import kotlinx.coroutines.flow.emptyFlow
fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_TYPE_VIDEO }
// Output-latency compensation. The spectrum is tapped upstream of the AudioTrack output buffer (and,
// over Bluetooth, the codec/transmission lag downstream of it), so the visual leads the speaker and
// must be delayed to match. Each hop is one 1024-sample FFT frame (~23 ms at 44.1 kHz). Tuned on a
// Pixel 9a against the 120-BPM beat of the synthetic demo clip (1 beat = 500 ms): wired/speaker wants
// ~20 hops, Bluetooth ~27 hops (the extra ~7 hops / ~160 ms is the BT codec latency).
//
// DEVICE/CODEC-SPECIFIC tuned constants. Android exposes no reliable output latency, and runtime
// auto-detection from player.currentPosition was tried and rejected (~3.4x off — it measures decode
// buffer depth, not the perceptual sync point). [rememberIsBluetoothOutput] only switches between
// these two presets by route; the BT figure is a per-codec average, so it may be off on other gear.
private const val FEED_VISUALIZER_DELAY_FRAMES = 20
private const val BT_VISUALIZER_DELAY_FRAMES = 27
@Composable
fun AudioPlayingAnimation(
controllerState: MediaControllerState,
waveform: WaveformData?,
mediaId: String,
style: VisualizerStyle,
modifier: Modifier = Modifier,
hasBlurhash: Boolean = false,
) {
if (hasBlurhash) return
var isAudio by remember { mutableStateOf(controllerState.controller.currentTracks.isAudio()) }
DisposableEffect(controllerState.controller) {
@@ -58,14 +89,94 @@ fun AudioPlayingAnimation(
onDispose { controllerState.controller.removeListener(listener) }
}
if (isAudio) {
if (waveform != null) {
Waveform(waveform, controllerState, modifier)
} else {
FakeWaveformAnimation(
mediaControllerState = controllerState,
modifier = modifier,
)
if (!isAudio) return
// Dim any blurhash/cover backdrop behind whatever we draw, for contrast and consistency across
// every visible style. OFF draws nothing, so the cover still shows cleanly there.
val drawModifier = if (hasBlurhash) modifier.background(Color.Black.copy(alpha = 0.45f)) else modifier
if (waveform != null) {
// NIP-A0 voice notes etc. that ship a precomputed waveform keep their seek bar.
Waveform(waveform, controllerState, drawModifier)
return
}
when (style) {
VisualizerStyle.CLASSIC -> FakeWaveformAnimation(mediaControllerState = controllerState, modifier = drawModifier)
VisualizerStyle.STATIC -> {
// StaticRenderer ignores the flow and shows a frozen frame.
val empty = remember { emptyFlow<Spectrum>() }
AudioVisualizer(style = VisualizerStyle.STATIC, spectrum = empty, modifier = drawModifier.audioVisualizerHeight())
}
VisualizerStyle.OFF -> Unit
VisualizerStyle.BARS,
VisualizerStyle.WAVES,
VisualizerStyle.RADIAL,
VisualizerStyle.AURORA,
-> {
val bluetooth by rememberIsBluetoothOutput()
val delayFrames = if (bluetooth) BT_VISUALIZER_DELAY_FRAMES else FEED_VISUALIZER_DELAY_FRAMES
val spectrum = remember(mediaId, delayFrames) { PcmTapRegistry.spectrumFor(mediaId).delayedByFrames(delayFrames) }
AudioVisualizer(style = style, spectrum = spectrum, modifier = drawModifier.audioVisualizerHeight())
}
}
}
/**
* Tracks whether audio is currently routed to a Bluetooth output, updating live as devices connect or
* disconnect. Bluetooth adds codec/transmission latency downstream of AudioTrack, so the visualizer
* needs a larger delay there. Keys off CONNECTED A2DP/LE output devices — a good proxy since A2DP
* captures media playback, and Android exposes no reliable "active media route" before API 31.
*/
@Composable
private fun rememberIsBluetoothOutput(): State<Boolean> {
val context = LocalContext.current
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager }
val isBluetooth = remember { mutableStateOf(audioManager.isBluetoothOutput()) }
DisposableEffect(audioManager) {
val callback =
object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
isBluetooth.value = audioManager.isBluetoothOutput()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
isBluetooth.value = audioManager.isBluetoothOutput()
}
}
audioManager?.registerAudioDeviceCallback(callback, null)
onDispose { audioManager?.unregisterAudioDeviceCallback(callback) }
}
return isBluetooth
}
private fun AudioManager?.isBluetoothOutput(): Boolean {
if (this == null) return false
return getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device ->
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
device.type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
device.type == AudioDeviceInfo.TYPE_BLE_SPEAKER
}
}
/**
* Fills the available height only when the parent gives a bounded height clearly larger than
* [fallback] (the full-screen media dialog); otherwise uses the fixed [fallback] strip. This avoids
* filling a small bounded feed cell and avoids collapsing to zero under the feed's unbounded lazy
* height. Always fills width.
*/
private fun Modifier.audioVisualizerHeight(fallback: Dp = 72.dp): Modifier =
fillMaxWidth().layout { measurable, constraints ->
val fallbackPx = fallback.roundToPx()
val targetHeight =
if (constraints.hasBoundedHeight) {
// Fill a clearly-large cell (full-screen dialog); otherwise use the fallback strip but
// never exceed the cell, so a small bounded cell can't overflow onto its neighbors.
if (constraints.maxHeight >= fallbackPx * 2) constraints.maxHeight else minOf(fallbackPx, constraints.maxHeight)
} else {
// Unbounded (lazy feed): the fallback strip avoids collapsing to zero.
fallbackPx
}
val placeable = measurable.measure(constraints.copy(minHeight = targetHeight, maxHeight = targetHeight))
layout(placeable.width, targetHeight) { placeable.placeRelative(0, 0) }
}
@@ -22,10 +22,16 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
@@ -38,18 +44,48 @@ class ExoPlayerBuilder(
val videoCache: VideoCache,
val dataSourceFactory: DataSource.Factory,
) {
fun build(context: Context): ExoPlayer =
ExoPlayer
.Builder(context)
fun build(context: Context): ExoPlayer {
val sink = PcmTapRegistry.newSink()
val renderersFactory =
object : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean,
): AudioSink =
DefaultAudioSink
.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setAudioProcessors(arrayOf(TeeAudioProcessor(sink)))
.build()
}
return ExoPlayer
.Builder(context, renderersFactory)
.apply {
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
setLoadControl(feedTunedLoadControl())
}.build()
.apply {
PcmTapRegistry.registerPlayer(this, sink)
addListener(
object : Player.Listener {
override fun onMediaItemTransition(
mediaItem: MediaItem?,
reason: Int,
) {
PcmTapRegistry.bind(mediaItem?.mediaId, sink)
}
},
)
PcmTapRegistry.bind(currentMediaItem?.mediaId, sink)
addListener(AspectRatioCacher(MediaAspectRatioCache))
addListener(KeepVideosPlaying(this))
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
}
}
companion object {
// Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video
@@ -209,6 +209,7 @@ class ExoPlayerPool(
coldPool.add(player)
}
} else {
PcmTapRegistry.unregisterPlayer(player)
player.release() // Release if pool is full.
}
}
@@ -223,8 +224,14 @@ class ExoPlayerPool(
warmPool.clear()
copy
}
warmSnapshot.forEach { it.player.release() }
coldPool.forEach { it.release() }
warmSnapshot.forEach {
PcmTapRegistry.unregisterPlayer(it.player)
it.player.release()
}
coldPool.forEach {
PcmTapRegistry.unregisterPlayer(it)
it.release()
}
coldPool.clear()
}
}.invokeOnCompletion {
@@ -0,0 +1,173 @@
/*
* 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.playback.playerPool
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import com.vitorpamplona.amethyst.commons.audio.AudioWindow
import com.vitorpamplona.amethyst.commons.audio.Fft
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.normalizeToPeakInPlace
import com.vitorpamplona.amethyst.commons.audio.toLogBins
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
/**
* A Media3 [TeeAudioProcessor.AudioBufferSink] that turns the decoded 16-bit PCM of the
* currently-playing track into a live [Spectrum] stream via FFT + log binning, emitting into the
* per-media-id flow [PcmTapRegistry] has bound it to. Only 16-bit PCM is handled; offload/passthrough
* bypass the processor chain so the flow simply stays empty (visualizer idles, never stale).
*/
@OptIn(UnstableApi::class)
class SpectrumAudioBufferSink(
private val fftSize: Int = 1024,
private val binCount: Int = 48,
) : TeeAudioProcessor.AudioBufferSink {
/** The per-media-id flow this sink currently feeds; set by [PcmTapRegistry.bind]. */
@Volatile
internal var output: MutableSharedFlow<Spectrum>? = null
/** The media id this sink is currently bound to; protects its flow from eviction. */
@Volatile
internal var boundMediaId: String? = null
private val window = AudioWindow.hann(fftSize)
private val mono = ShortArray(fftSize)
private val scratch = FloatArray(fftSize)
private val re = DoubleArray(fftSize)
private val im = DoubleArray(fftSize)
private val mags = FloatArray(fftSize / 2 + 1)
private var filled = 0
private var channels = 1
private var encoding = C.ENCODING_PCM_16BIT
@OptIn(ExperimentalCoroutinesApi::class)
override fun flush(
sampleRateHz: Int,
channelCount: Int,
encoding: Int,
) {
this.channels = channelCount.coerceAtLeast(1)
this.encoding = encoding
filled = 0
output?.resetReplayCache()
}
override fun handleBuffer(buffer: ByteBuffer) {
if (encoding != C.ENCODING_PCM_16BIT) return
val pcm = buffer.order(ByteOrder.LITTLE_ENDIAN)
while (pcm.remaining() >= 2 * channels) {
val sample = pcm.short // first channel of the frame
if (channels > 1) pcm.position(pcm.position() + (channels - 1) * 2) // skip remaining channels
mono[filled++] = sample
if (filled == fftSize) {
emitSpectrum()
filled = 0
}
}
}
// Reuses pre-allocated working buffers; the only per-frame allocation is the published bins.
private fun emitSpectrum() {
AudioWindow.shortsToWindowedInto(mono, window, scratch)
Fft.magnitudesInto(scratch, re, im, mags)
// Skip the DC bin (index 0): toLogBins ignores it, so letting a DC/offset component be the
// peak would scale every audible bin toward zero and wash the spectrum out.
mags.normalizeToPeakInPlace(fromIndex = 1)
output?.tryEmit(Spectrum(mags.toLogBins(binCount)))
}
}
/**
* Routes each pooled player's decoded-PCM spectrum to a stable per-media-id flow the UI subscribes
* to by media URL. Flows are created on demand (so a UI subscriber can attach before playback binds
* the sink) and the map is bounded: past [MAX_TRACKED_FLOWS], the least-recently-used flows that no
* live sink is currently feeding are evicted, so a long feed session can't grow the map without limit.
*/
@OptIn(UnstableApi::class)
object PcmTapRegistry {
private const val MAX_TRACKED_FLOWS = 64
private val lock = Any()
// access-order LinkedHashMap → eldest (least-recently-used) entries iterate first for eviction.
private val flowsByMediaId = LinkedHashMap<String, MutableSharedFlow<Spectrum>>(16, 0.75f, true)
private val sinkByPlayer = ConcurrentHashMap<Any, SpectrumAudioBufferSink>()
fun newSink(): SpectrumAudioBufferSink = SpectrumAudioBufferSink()
fun registerPlayer(
playerKey: Any,
sink: SpectrumAudioBufferSink,
) {
sinkByPlayer[playerKey] = sink
}
/** Points [sink]'s output at the flow for [mediaId] (the item it now plays), or detaches it. */
fun bind(
mediaId: String?,
sink: SpectrumAudioBufferSink,
) {
// Mutate the sink's binding under the same lock that guards flow eviction (flowFor is
// reentrant), so the eviction guard never observes a half-updated boundMediaId.
synchronized(lock) {
sink.boundMediaId = mediaId
sink.output = mediaId?.let { flowFor(it) }
}
}
fun unregisterPlayer(playerKey: Any) {
synchronized(lock) {
sinkByPlayer.remove(playerKey)?.let {
it.output = null
it.boundMediaId = null
}
}
}
/** Stable spectrum stream for a media URL; frames arrive once a player binds to it and plays. */
fun spectrumFor(mediaId: String): Flow<Spectrum> = flowFor(mediaId)
private fun flowFor(mediaId: String): MutableSharedFlow<Spectrum> =
synchronized(lock) {
flowsByMediaId.getOrPut(mediaId) {
if (flowsByMediaId.size >= MAX_TRACKED_FLOWS) {
val iter = flowsByMediaId.entries.iterator()
while (iter.hasNext() && flowsByMediaId.size >= MAX_TRACKED_FLOWS) {
val entry = iter.next()
// Never evict a flow a live sink is currently feeding, nor one a composable is
// still collecting — otherwise a later bind() would create a fresh instance the
// sink feeds while the old subscriber keeps collecting the dead one (blank viz).
val fedByLiveSink = sinkByPlayer.values.any { it.boundMediaId == entry.key }
val stillCollected = entry.value.subscriptionCount.value > 0
if (!fedByLiveSink && !stillCollected) iter.remove()
}
}
MutableSharedFlow(replay = 1, extraBufferCapacity = 1)
}
}
}
@@ -170,6 +170,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AudioVisualizerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
@@ -375,6 +376,7 @@ fun BuildNavigation(
composableFromEnd<Route.ComposeSettings> { ComposeSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
@@ -325,6 +325,8 @@ sealed class Route {
@Serializable object ReactionsSettings : Route()
@Serializable object AudioVisualizerSettings : Route()
@Serializable object BottomBarSettings : Route()
@Serializable object HomeTabsSettings : Route()
@@ -117,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
@@ -133,6 +134,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderFundraiser
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
@@ -208,12 +210,14 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@@ -1258,6 +1262,14 @@ private fun RenderNoteRow(
RenderGoal(baseNote, accountViewModel, nav)
}
is FundraiserEvent -> {
RenderFundraiser(baseNote, makeItShort, accountViewModel, nav)
}
is BirdexEvent -> {
RenderBirdex(baseNote)
}
is HighlightEvent -> {
RenderHighlight(
baseNote,
@@ -114,31 +114,6 @@ fun ReplyInformationChannel(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyToLabel(
replyingDirectlyTo: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val user = replyingDirectlyTo.author ?: return
FlowRow {
Text(
stringRes(id = R.string.replying_to),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.placeholderText,
)
ReplyInfoMention(
user = user,
prefix = "",
accountViewModel = accountViewModel,
onUserTagClick = { nav.nav(routeFor(it)) },
)
}
}
@Composable
private fun ReplyInfoMention(
user: User,
@@ -36,10 +36,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.utils.RandomInstance
@Composable
fun ZapPollField(postViewModel: ShortNotePostViewModel) {
val optionsList = postViewModel.zapPollOptions
// Shares the same `pollOptions` text fields as the regular poll so switching poll types keeps the text.
val optionsList = postViewModel.pollOptions
Column(
modifier = Modifier.fillMaxWidth(),
) {
@@ -55,8 +58,7 @@ fun ZapPollField(postViewModel: ShortNotePostViewModel) {
Button(
onClick = {
// postViewModel.pollOptions[postViewModel.pollOptions.size] = ""
optionsList[optionsList.size] = ""
optionsList[optionsList.size] = OptionTag(RandomInstance.randomChars(6), "")
},
border =
BorderStroke(
@@ -47,7 +47,7 @@ fun ZapPollOption(
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = {
pollViewModel.removeZapPollOption(optionIndex)
pollViewModel.removePollOption(optionIndex)
},
) {
Icon(
@@ -59,9 +59,9 @@ fun ZapPollOption(
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.zapPollOptions[optionIndex] ?: "",
value = pollViewModel.pollOptions[optionIndex]?.label ?: "",
onValueChange = {
pollViewModel.updateZapPollOption(optionIndex, it)
pollViewModel.updatePollOption(optionIndex, it)
},
label = {
Text(
@@ -0,0 +1,84 @@
/*
* 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.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
/** How many species names to list before collapsing into a "+N more" suffix. */
private const val SPECIES_PREVIEW_LIMIT = 6
/**
* Minimal, fixed-size summary card for a Birdstar "Birdex" (kind 12473).
*
* The event has no body and no images, only a species list. To keep the card
* bounded regardless of how many species a Birdex holds, we show the count and a
* short preview of scientific names with a "+N more" suffix no images, no
* expansion, no network calls. The card is identical in the feed and the opened
* view, so it takes no makeItShort flag.
*/
@Composable
fun RenderBirdex(baseNote: Note) {
val noteEvent = baseNote.event as? BirdexEvent ?: return
val names = remember(noteEvent) { noteEvent.speciesNames() }
val preview = remember(names) { names.take(SPECIES_PREVIEW_LIMIT) }
val remaining = names.size - preview.size
val joined = remember(preview) { preview.joinToString(", ") }
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = pluralStringResource(R.plurals.birdex_species_count, names.size, names.size),
style = MaterialTheme.typography.titleMedium,
)
if (preview.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text(
text =
if (remaining > 0) {
pluralStringResource(R.plurals.birdex_species_preview_more, remaining, joined, remaining)
} else {
joined
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,196 @@
/*
* 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.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.launch
@Composable
fun RenderFundraiser(
baseNote: Note,
makeItShort: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? FundraiserEvent ?: return
val title = remember(noteEvent) { noteEvent.title()?.ifBlank { null } }
val body = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val coverImage = remember(noteEvent) { noteEvent.coverImage()?.ifBlank { null } }
val goalAmountSats = remember(noteEvent) { noteEvent.goal() ?: 0L }
val deadline = remember(noteEvent) { noteEvent.deadline() }
val wallets = remember(noteEvent) { noteEvent.wallets() }
val topics = remember(noteEvent) { noteEvent.topics() }
Column(MaterialTheme.colorScheme.replyModifier) {
coverImage?.let {
Box {
MyAsyncImage(
imageUrl = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.FillWidth,
mainImageModifier = Modifier.fillMaxWidth(),
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
}
}
Column(Modifier.padding(10.dp)) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
}
body?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (makeItShort) 5 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(10.dp))
}
if (goalAmountSats > 0) {
GoalProgressBar(
note = baseNote,
goalAmountSats = goalAmountSats,
accountViewModel = accountViewModel,
)
}
deadline?.let {
if (it > TimeUtils.now()) {
val context = LocalContext.current
Spacer(Modifier.height(6.dp))
Text(
text = stringRes(R.string.fundraiser_ends, timeAheadNoDot(it, context)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
if (wallets.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
OnChainDonation(wallets, accountViewModel)
}
if (topics.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
DisplayUncitedHashtags(
event = noteEvent,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun OnChainDonation(
wallets: List<String>,
accountViewModel: AccountViewModel,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Text(
text = stringRes(R.string.fundraiser_onchain_donation),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(Modifier.height(4.dp))
wallets.forEach { address ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
clipboard.setText(address)
accountViewModel.toastManager.toast(
R.string.copy_to_clipboard,
R.string.copied_to_clipboard,
)
}
},
) {
Text(
text = address,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
CopyIcon(modifier = Size18Modifier)
}
}
}
@@ -35,15 +35,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -114,12 +115,14 @@ fun RenderTextEvent(
}
ReplyRenderType.LINE -> {
ReplyToLabel(
replyingDirectlyTo = replyingDirectlyTo,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = HalfVertSpacer)
val parentAuthor = replyingDirectlyTo.author
if (parentAuthor != null) {
ReplyToLabel(
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
onClick = { nav.nav(routeFor(parentAuthor)) },
)
Spacer(modifier = HalfVertSpacer)
}
}
}
} else if (!makeItShort && noteEvent is CommentEvent) {
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
@@ -1309,6 +1310,13 @@ class AccountViewModel(
account.changeVideoPlayerButtonItems(items)
}
fun audioVisualizerFlow(): StateFlow<VisualizerStyle> = account.settings.syncedSettings.media.audioVisualizer
fun changeAudioVisualizer(style: VisualizerStyle) =
launchSigner {
account.changeAudioVisualizer(style)
}
fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
@@ -30,8 +31,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollow
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -65,6 +68,8 @@ class FollowPackFeedNewThreadFeedFilter(
WikiNoteEvent.KIND,
NipTextEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
BirdexEvent.KIND,
LongTextNoteEvent.KIND,
)
}
@@ -133,8 +138,9 @@ class FollowPackFeedNewThreadFeedFilter(
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is RepostEvent ||
noteEvent is GenericRepostEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
noteEvent is ZapPollEvent ||
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -35,8 +36,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -104,8 +103,7 @@ class HashtagFeedFilter(
): Boolean =
(
event is TextNoteEvent ||
event is RepostEvent ||
event is GenericRepostEvent ||
event.isRenderableRepost() ||
event is LongTextNoteEvent ||
event is WikiNoteEvent ||
event is ChannelMessageEvent ||
@@ -631,7 +631,7 @@ private fun NewPostScreenBody(
confirmButton = {
TextButton(onClick = {
showAlwaysOnPrompt = false
nav.nav(Route.Settings)
nav.nav(Route.NotificationSettings)
}) {
Text(stringRes(R.string.schedule_post_always_on_prompt_open_settings))
}
@@ -257,6 +257,8 @@ open class ShortNotePostViewModel :
get() = voiceAnonymization.processingPreset
// Polls
// Both regular polls and zap polls share the same `pollOptions` text fields so that switching
// between the two poll types never hides or discards the text the user already typed.
var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false)
var pollOptions: SnapshotStateMap<Int, OptionTag> = newStateMapPollOptions()
@@ -266,7 +268,6 @@ open class ShortNotePostViewModel :
// ZapPolls
var canUseZapPoll by mutableStateOf(false)
var wantsZapPoll by mutableStateOf(false)
var zapPollOptions: SnapshotStateMap<Int, String> = newStateMapZapPollOptions()
var zapPollValueMaximum by mutableStateOf<Long?>(null)
var zapPollValueMinimum by mutableStateOf<Long?>(null)
var zapPollConsensusThreshold: Int? = null
@@ -794,7 +795,8 @@ open class ShortNotePostViewModel :
wantsZapPoll = polls.isNotEmpty()
polls.forEach { tag ->
zapPollOptions[tag.index] = tag.descriptor
val current = pollOptions[tag.index]
pollOptions[tag.index] = OptionTag(current?.code ?: RandomInstance.randomChars(6), tag.descriptor)
}
zapPollValueMinimum = draftEvent.minAmount()
@@ -1011,7 +1013,7 @@ open class ShortNotePostViewModel :
imetas(usedAttachments)
}
} else if (wantsZapPoll) {
val options = zapPollOptions.map { PollOptionTag(it.key, it.value) }
val options = pollOptions.map { PollOptionTag(it.key, it.value.label) }
if (options.isEmpty()) return null
ZapPollEvent.build(tagger.message, options) {
@@ -1230,7 +1232,6 @@ open class ShortNotePostViewModel :
closedAt = TimeUtils.oneDayAhead()
wantsZapPoll = false
zapPollOptions = newStateMapZapPollOptions()
zapPollValueMaximum = null
zapPollValueMinimum = null
zapPollConsensusThreshold = null
@@ -1356,12 +1357,6 @@ open class ShortNotePostViewModel :
1 to OptionTag(RandomInstance.randomChars(6), ""),
)
private fun newStateMapZapPollOptions(): SnapshotStateMap<Int, String> =
mutableStateMapOf(
0 to "",
1 to "",
)
fun canPost(): Boolean {
// Voice messages can be posted without text (with either uploaded or pending recording)
if (voiceMetadata != null || voiceRecording != null) {
@@ -1385,7 +1380,8 @@ open class ShortNotePostViewModel :
(
!wantsZapPoll ||
(
zapPollOptions.values.all { it.isNotEmpty() } &&
pollOptions.isNotEmpty() &&
pollOptions.all { it.value.label.isNotEmpty() } &&
isValidValueMinimum.value &&
isValidValueMaximum.value
)
@@ -1620,35 +1616,6 @@ open class ShortNotePostViewModel :
}
}
fun removeZapPollOption(optionIndex: Int) {
zapPollOptions.removeOrderedZapPoll(optionIndex)
draftTag.newVersion()
}
private fun MutableMap<Int, String>.removeOrderedZapPoll(index: Int) {
val keyList = keys
val elementList = values.toMutableList()
run stop@{
for (i in index until elementList.size) {
val nextIndex = i + 1
if (nextIndex == elementList.size) return@stop
elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" }
}
}
elementList.removeAt(elementList.size - 1)
val newEntries = keyList.zip(elementList) { key, content -> Pair(key, content) }
this.clear()
this.putAll(newEntries)
}
fun updateZapPollOption(
optionIndex: Int,
text: String,
) {
zapPollOptions[optionIndex] = text
draftTag.newVersion()
}
fun toggleMarkAsSensitive() {
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
draftTag.newVersion()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -29,12 +30,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -67,6 +70,8 @@ class HomeNewThreadFeedFilter(
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
BirdexEvent.KIND,
LongTextNoteEvent.KIND,
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
@@ -122,8 +127,9 @@ class HomeNewThreadFeedFilter(
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is RepostEvent ||
noteEvent is GenericRepostEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
noteEvent is ZapPollEvent ||
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
@@ -27,8 +28,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -37,8 +40,6 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
@@ -77,8 +78,9 @@ class UserProfileMutualFeedFilter(
(
it.event is TextNoteEvent ||
it.event is ClassifiedsEvent ||
it.event is RepostEvent ||
it.event is GenericRepostEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
it.event is NipTextEvent ||
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
@@ -27,12 +28,14 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -40,8 +43,6 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
@@ -83,8 +84,9 @@ class UserProfileNewThreadFeedFilter(
it.event is TextNoteEvent ||
it.event is CommentEvent ||
it.event is ClassifiedsEvent ||
it.event is RepostEvent ||
it.event is GenericRepostEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
it.event is NipTextEvent ||
@@ -0,0 +1,161 @@
/*
* 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.screen.loggedIn.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SyntheticSpectrum
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.FakeWaveformAnimation
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.flow.Flow
@Composable
fun AudioVisualizerSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.audio_visualizer_settings), nav)
},
) { padding ->
AudioVisualizerSettingsContent(accountViewModel, Modifier.padding(padding))
}
}
@Composable
fun AudioVisualizerSettingsContent(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val selected by accountViewModel.audioVisualizerFlow().collectAsStateWithLifecycle()
val previewSpectrum = remember { SyntheticSpectrum.flow(48) }
LazyColumn(modifier = modifier.fillMaxWidth()) {
item {
Text(
text = stringRes(R.string.audio_visualizer_settings_description),
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
modifier = Modifier.padding(16.dp),
)
}
items(VisualizerStyle.entries) { style ->
VisualizerStyleRow(
style = style,
selected = style == selected,
previewSpectrum = previewSpectrum,
onClick = { accountViewModel.changeAudioVisualizer(style) },
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
@Composable
private fun VisualizerStyleRow(
style: VisualizerStyle,
selected: Boolean,
previewSpectrum: Flow<Spectrum>,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RadioButton(selected = selected, onClick = onClick)
Column(Modifier.weight(1f)) {
Text(visualizerStyleName(style), style = MaterialTheme.typography.bodyLarge)
}
Box(
modifier =
Modifier
.size(width = 120.dp, height = 56.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF0C0C10)),
) {
if (style == VisualizerStyle.CLASSIC) {
val progress = remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
while (true) {
withFrameMillis { ms -> progress.floatValue = (ms % 1500L) / 1500f }
}
}
FakeWaveformAnimation(progress, 40, Modifier.fillMaxWidth().height(56.dp))
} else {
// OFF → OffRenderer (nothing), STATIC → frozen bars, others → live preview.
AudioVisualizer(style = style, spectrum = previewSpectrum, modifier = Modifier.fillMaxWidth().height(56.dp))
}
}
}
}
@Composable
private fun visualizerStyleName(style: VisualizerStyle): String =
when (style) {
VisualizerStyle.CLASSIC -> stringRes(R.string.audio_visualizer_classic)
VisualizerStyle.OFF -> stringRes(R.string.audio_visualizer_off)
VisualizerStyle.BARS -> stringRes(R.string.audio_visualizer_bars)
VisualizerStyle.WAVES -> stringRes(R.string.audio_visualizer_waves)
VisualizerStyle.RADIAL -> stringRes(R.string.audio_visualizer_radial)
VisualizerStyle.AURORA -> stringRes(R.string.audio_visualizer_aurora)
VisualizerStyle.STATIC -> stringRes(R.string.audio_visualizer_static)
}
@@ -71,6 +71,7 @@ fun buildSettingsCatalog(
symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds),
symEntry(R.string.reactions, MaterialSymbols.FavoriteBorder, R.string.reactions_search_keywords, Route.UpdateReactionType),
symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings),
symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings),
symEntry(R.string.zaps, MaterialSymbols.Bolt, R.string.zaps_search_keywords, Route.UpdateZapAmount()),
symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets),
symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters),
@@ -147,6 +147,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
@@ -157,6 +158,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderFundraiser
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
@@ -221,12 +223,14 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@@ -765,6 +769,10 @@ private fun FullBleedNoteCompose(
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
} else if (noteEvent is GoalEvent) {
RenderGoal(baseNote, accountViewModel, nav)
} else if (noteEvent is FundraiserEvent) {
RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav)
} else if (noteEvent is BirdexEvent) {
RenderBirdex(baseNote)
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is RelayDiscoveryEvent) {
+62 -18
View File
@@ -473,7 +473,7 @@
<string name="scheduled_posts_event_id_copied">टीका विभेदक की अनुकृति की गई</string>
<string name="polls">मतदान</string>
<string name="open_polls">खुला</string>
<string name="closed_polls">अवरुद्ध</string>
<string name="closed_polls">आवृत</string>
<string name="badges">पदक</string>
<string name="communities">समुदाय</string>
<string name="new_community">नया समुदाय</string>
@@ -597,9 +597,9 @@
<string name="nest_listen_to_recording">अभिलेख सुनें</string>
<string name="nest_chat_send_failed_title">सन्देश प्रेषण असफल</string>
<string name="nest_no_app_to_open_link">इस योजक को खोलने के लिए कोई क्रमक स्थापित नहीं।</string>
<string name="nest_close_room_confirm_title">क्या इस शाला को अवरोधित करें।</string>
<string name="nest_close_room_confirm_body">सभी उपस्थित वियोजित हो जाएँगे। शाला अवरुद्ध दिखेगा सूचनावली में।</string>
<string name="nest_close_room_confirm_action">शाला अवरोधन</string>
<string name="nest_close_room_confirm_title">क्या इस शाला को आवृत करें।</string>
<string name="nest_close_room_confirm_body">सभी उपस्थित वियोजित हो जाएँगे। शाला आवृत दिखेगा सूचनावली में।</string>
<string name="nest_close_room_confirm_action">शाला आवृत करें</string>
<string name="nest_create_when_in_past">भविष्य आरम्भ समय का चयन।</string>
<string name="nest_audio_failed">ध्वनि असफल : %1$s</string>
<string name="nest_audio_unavailable">इस शाला के लिए ध्वनि उपलब्ध नहीं</string>
@@ -636,10 +636,10 @@
<string name="nest_presence_left">निलय छोडकर गए</string>
<string name="nest_leave">निर्गमन</string>
<string name="nest_leave_host_title">कया इस निलय को अवरुद्ध करें।</string>
<string name="nest_leave_host_body">आप निमन्त्रक हैं। शाला अवरुद्ध करने से सभी वियोजित हो जाएँगे। \"केवल निर्गमन\" का चयन करें यदि आप लौटना चाहते हें कुछ समय पश्चात। शाला स्वतः अवरुद्ध होगी ८ घण्टों की निष्क्रियता पश्चात।</string>
<string name="nest_leave_host_close">शाला अवरोधन</string>
<string name="nest_leave_host_body">आप निमन्त्रक हैं। शाला आवृत करने से सभी वियोजित हो जाएँगे। \"केवल निर्गमन\" का चयन करें यदि आप लौटना चाहते हें कुछ समय पश्चात। शाला स्वतः आवृत होगी ८ घण्टों की निष्क्रियता पश्चात।</string>
<string name="nest_leave_host_close">शाला आवृत करें</string>
<string name="nest_leave_host_just_leave">केवल निर्गमन</string>
<string name="nest_leave_host_close_failed">शाला अवरुद्ध चिह्नित करने में असफल। तथापि निर्गमन चालू। शाला स्वतः अवरुद्ध हो जाएगी।</string>
<string name="nest_leave_host_close_failed">शाला आवृत चिह्नित करने में असफल। तथापि निर्गमन चालू। शाला स्वतः आवृत हो जाएगी।</string>
<plurals name="nest_listener_count">
<item quantity="one">%1$d श्रोता</item>
<item quantity="other">%1$d श्रोतागण</item>
@@ -655,7 +655,7 @@
<string name="nest_reactions_button">प्रतिक्रिया</string>
<string name="nest_edit_title">शाला सम्पादन</string>
<string name="nest_edit_save">अभिलेखन</string>
<string name="nest_close_action">शाला अवरोधन</string>
<string name="nest_close_action">शाला आवृत करें</string>
<string name="nest_overflow_menu">शाला क्रियाएँ</string>
<string name="nest_hand_raise_queue_title">उठे हुए हाथ</string>
<string name="nest_hand_raise_approve">स्वीकार</string>
@@ -734,7 +734,7 @@
<string name="route_music_tracks">संगीत</string>
<string name="route_music_playlists">संगीतसूचियाँ</string>
<string name="route_podcast_episodes">कडियाँ</string>
<string name="route_podcasts">प्रसारयान</string>
<string name="route_podcasts">ुटप्रसार</string>
<string name="podcast_view_episodes">कडियाँ देखें</string>
<string name="podcast_episodes_section">कडियाँ</string>
<string name="podcast_no_episodes">कोई कडी प्राप्त नहीं अब तक</string>
@@ -774,6 +774,12 @@
<string name="add_to_public_bookmarks">सार्वजनिक स्मर्तव्य सूची में जोडें</string>
<string name="remove_from_private_bookmarks">निजी स्मर्तव्य सूची से हटाएँ</string>
<string name="remove_from_public_bookmarks">सार्वजनिक स्मर्तव्य सूची से हटाएँ</string>
<string name="add_hashtag_label">विषयसूचक जोडें</string>
<string name="add_hashtag_label_title">विषयसूचक जोडें</string>
<string name="add_hashtag_label_explainer">सार्वजनिक रूप से इस पत्र के साथ विषयसूचक (निप॰३२ चिप्पी) जोडें। जो लोग आपका अनुचरण करते हैं उस विषयसूचक की सूचनावली में इस पत्र को देखेंगे।</string>
<string name="add_hashtag_label_field">विषयसूचक</string>
<string name="add_hashtag_label_confirm">जोडें</string>
<string name="hashtag_label_added_by">के द्वारा जोडा गया</string>
<string name="pinned_notes">टँगे गए पत्र</string>
<string name="pinned_notes_explainer">आपके टँगे गए टीकाएँ</string>
<string name="pin_to_profile">परिचय के साथ जोडें</string>
@@ -842,6 +848,29 @@
<string name="quick_zap_amounts">शीघ्र ज्साप संख्याएँ</string>
<string name="quick_zap_amounts_explainer">ज्साप घुण्डी दबाने पर दिखाया जाता है। एक संख्या दबाएँ उसे हटाने के लिए। उसे रिक्त छोड दें तो प्रत्येक बार संख्या प्रविष्ट करने के लिए पूछेगा।</string>
<string name="send_onchain_instead">खण्डश्रृंखला सत्यापित विधि से भेजें इसके स्थान पर</string>
<string name="reload_mint_title">पुनःभरण टकसाल</string>
<string name="reload_mint_sats_amount">%1$s साट्स</string>
<string name="reload_mint_topup_label">पुनःभरण मात्रा</string>
<string name="reload_mint_section_to">पुनःभरण टकसाल</string>
<string name="reload_mint_section_from">इस से धनराशि</string>
<string name="reload_mint_pay_lightning">लैटनिंग द्वारा भुगतान</string>
<string name="reload_mint_lightning_desc">नव्य ईकाश॰ का टंकण करें अपने लैटनिंग धनकोष से</string>
<string name="reload_mint_not_enough">यहाँ पर्याप्त नहीं</string>
<string name="reload_mint_available">%1$s साट्स उपलब्ध</string>
<string name="reload_mint_confirm">पुनःभरण तथा ज्साप भेजें</string>
<string name="reload_mint_send_confirm">ज्साप भेजें</string>
<string name="reload_mint_awaiting_payment">भुगतान की प्रतीक्षा…</string>
<string name="reload_mint_retry">पुनःप्रयास करें</string>
<string name="reload_mint_summary">पुनःभरण %1$s साट्स %2$s तक तथा ज्साप %3$s को %4$s साट्स। शुल्क लगभग %5$s साट्स</string>
<string name="reload_mint_summary_funded">ज्साप %1$s को %2$s साट्स %3$s से</string>
<string name="reload_mint_recipient_fallback">प्राप्तकर्ता</string>
<string name="reload_mint_needs_more">%1$s साट्स अधिक आवश्यक</string>
<string name="reload_mint_funded">वित्तपोषित</string>
<string name="reload_mint_copy_invoice">चालान अनुकृति</string>
<string name="topup_mint_title">पुनःभरण टकसाल</string>
<string name="topup_mint_action">इस टकसाल का पुनःभरण</string>
<string name="topup_mint_amount_label">जोडने की मात्रा</string>
<string name="topup_mint_confirm">पुनःभरण</string>
<string name="zap_privacy_section">ज्साप गोपनीयता</string>
<string name="zap_type_section_explainer">नियन्त्रण करता है कि आपका परिचय कैसे दिखाया जाता है ज्साप भेजने पर।</string>
<string name="wallet_connect_connect_app">संयोजन धनकोष</string>
@@ -866,7 +895,7 @@
<string name="poll_closing_time">के पश्चात समाप्त करें</string>
<string name="poll_closing_time_days">दिन</string>
<string name="poll_unable_to_vote">निर्वाचन करने में असफल</string>
<string name="poll_is_closed_explainer">मतदान अवरोधित है नये निर्वाचनों के लिए</string>
<string name="poll_is_closed_explainer">मतदान आवृत है नये निर्वाचनों के लिए</string>
<string name="poll_zap_amount">ज्साप मात्रा</string>
<string name="one_vote_per_user_on_atomic_votes">प्रत्येक उपयोगकर्ता को केवल एक निर्वाचन करने की अनुमति है इस प्रकार के मतदान में।</string>
<string name="looking_for_event">"घटना %1$s के लिए खोज चल रहा है"</string>
@@ -1317,7 +1346,7 @@
<string name="live_stream_has_ended">तत्क्षणप्रसार समाप्त</string>
<string name="meeting_space_open_tag">खुला</string>
<string name="meeting_space_private_tag">निजी</string>
<string name="meeting_space_closed_tag">निबद्ध</string>
<string name="meeting_space_closed_tag">आवृत</string>
<string name="meeting_space_planned_tag">समय निर्धारित</string>
<string name="meeting_space_planned_starts_at">आरम्भ %1$s</string>
<string name="are_you_sure_you_want_to_log_out">निर्गमनांकन करने पर आपकी सारी स्थानीय जानकारी मिट जाएगी। सुनिश्चित करें कि आपके निजी कुंचिकाएँ सुरक्षित रखें हैं अपनी लेखा नहीं खोना चाहते हैं तो। क्या आप आगे बढना चाहते हैं?</string>
@@ -1345,6 +1374,8 @@
<string name="account_settings">लेखा स्थापना विकल्प</string>
<string name="app_settings">क्रमक स्थापना विकल्प</string>
<!-- Settings search -->
<string name="settings_search_placeholder">खोज स्थापना विकल्प</string>
<string name="settings_search_no_results">\"%1$s\" के लिए कोई स्थापना विकल्प प्राप्त नहीं</string>
<!-- Settings search keywords: an English concept/protocol index that supplements the
(translated) row titles. Marked translatable="false" so protocol terms (blossom, nsec,
negentropy, …) stay searchable in every locale and aren't sent to translators. -->
@@ -1476,6 +1507,9 @@
<string name="copy_nprofile_to_clipboard">टाँकाफलक में एन॰परिचय की अनुकृति करें</string>
<string name="copy_npub_to_clipboard">टाँकाफलक में एनपुब॰ की अनुकृति करें</string>
<string name="share_or_save">बाँटें अथवा अभिलेखन करें</string>
<string name="share_target_as_dm">सीधासन्देश के रूप में भेजें</string>
<string name="share_to_dm_title">को भेजें…</string>
<string name="share_to_dm_start_new">नया सन्देश</string>
<string name="copy_url_to_clipboard">टाँकाफलक में जालपता की अनुकृति करें</string>
<string name="copy_the_note_id_to_the_clipboard">टाँकाफलक में टीका विभेदक की अनुकृति करें</string>
<string name="add_media_to_gallery">अभिलेख को चित्रालय में जोडें</string>
@@ -1589,7 +1623,7 @@
<string name="notification_channel_status_off">निष्क्रिय</string>
<string name="select_push_server">संयुक्तप्रेषण क्रमक का चयन करें</string>
<string name="push_server_title">प्रेषित सूचना</string>
<string name="push_server_explainer">स्थापित संयुक्तप्रेषण क्रमकों से</string>
<string name="push_server_explainer">एक संयुक्तप्रेषण क्रमक का चयन करें सूचनाओं को भेजने के लिए जब अमेथिस्ट आवृत हो।</string>
<string name="push_server_none">कुछ भी नहीं</string>
<string name="push_server_none_explainer">प्रेषित सूचनाएँ अक्षम करता है</string>
<string name="push_server_uses_app_explainer">%1$s क्रमक का प्रयोग करता है</string>
@@ -1987,7 +2021,7 @@
<string name="add_a_tag">विषयसूचक जोडें\u2026</string>
<string name="start_writing_article">अपना लेख लिखना आरम्भ करें\u2026</string>
<string name="open_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें</string>
<string name="close_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें</string>
<string name="close_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को आवृत करें</string>
<string name="reply_description">उत्तर</string>
<string name="jump_to_parent_reply">उस टीका तक जाएँ जिसका यह उत्तर है</string>
<string name="boost_or_quote_description">उद्धृत करें अथवा टीका लिखें</string>
@@ -2166,7 +2200,7 @@
<string name="git_clone_address">अनुकृति :</string>
<string name="git_status_open">खुला</string>
<string name="git_status_merged">विलीन</string>
<string name="git_status_closed">अवरुद्ध</string>
<string name="git_status_closed">आवृत</string>
<string name="git_status_draft">पाण्डुलिपि</string>
<string name="git_repo_tab_overview">रूपरेखा विवरण</string>
<string name="git_repo_tab_issues">समस्याएँ</string>
@@ -2420,10 +2454,10 @@
<string name="kind_audio_track">ध्वनि अभिलेख</string>
<string name="kind_music_track">संगीत अभिलेख</string>
<string name="kind_music_playlist">संगीतसूची</string>
<string name="kind_podcast_episode">प्रसारयान कडी</string>
<string name="kind_podcast_metadata">प्रसारयान कार्यक्रम</string>
<string name="kind_authored_podcasts">कृत प्रसारयान</string>
<string name="kind_favorite_podcasts">प्रिय प्रसारयान</string>
<string name="kind_podcast_episode">ुटप्रसार कडी</string>
<string name="kind_podcast_metadata">ुटप्रसार कार्यक्रम</string>
<string name="kind_authored_podcasts">कृत पुटप्रसार</string>
<string name="kind_favorite_podcasts">प्रिय पुटप्रसार</string>
<string name="kind_badge_awards">पदक पुरस्कार</string>
<string name="kind_badge_definitions">पदक परिभाषाएँ</string>
<string name="kind_accepted_badge_set">स्वीकृत पदक समुच्चय</string>
@@ -2781,6 +2815,16 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">यह उद्देश्य समाप्त हो चुका है</string>
<string name="goal_progress">%1$s वित्तपोषित %2$s साट्स उद्देश्य में से</string>
<string name="fundraiser_ends">समाप्ति %1$s</string>
<string name="fundraiser_onchain_donation">खण्डश्रृंखलाबद्ध दान</string>
<plurals name="birdex_species_count">
<item quantity="one">बिर्डेक्स। %1$d प्रजाति</item>
<item quantity="other">बिर्डेक्स। %1$d प्रजातियाँ</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="one">%1$s सं॰ %2$d अधिक</item>
<item quantity="other">%1$s सं॰ %2$d अधिक</item>
</plurals>
<string name="goal_amount_label">उद्देश्य संख्या (साट्स)</string>
<string name="goal_amount_placeholder">१०००००</string>
<string name="goal_description_label">आपके उद्देश्य का विवरण करें</string>
@@ -774,6 +774,12 @@
<string name="add_to_public_bookmarks">Hozzáadás a nyilvános könyvjelzőkhöz</string>
<string name="remove_from_private_bookmarks">Törlés a privát könyvjelzőkből</string>
<string name="remove_from_public_bookmarks">Törlés a nyilvános könyvjelzőkből</string>
<string name="add_hashtag_label">Kulcsszó hozzáadása</string>
<string name="add_hashtag_label_title">Egy kulcsszó hozzáadása</string>
<string name="add_hashtag_label_explainer">Címkézze meg ezt a bejegyzést nyilvánosan egy kulcsszóval (NIP-32 címke). A követői a kulcsszó hírfolyamában fogják látni.</string>
<string name="add_hashtag_label_field">Kulcsszó</string>
<string name="add_hashtag_label_confirm">Hozzáadás</string>
<string name="hashtag_label_added_by">hozzáadta:</string>
<string name="pinned_notes">Rögzített bejegyzések</string>
<string name="pinned_notes_explainer">Saját kitűzött bejegyzések</string>
<string name="pin_to_profile">Rögzítés a profilhoz</string>
@@ -840,8 +846,31 @@
<string name="wallet_connect_status_not_connected">Nem kapcsolódott</string>
<string name="wallet_connect_manual_config">Speciális: kapcsolat részleteinek kézi megadása</string>
<string name="quick_zap_amounts">Gyors Zap-összegek</string>
<string name="quick_zap_amounts_explainer">A zap gomb megnyomásakor jelenik meg. Érintse meg az összeget, hogy eltávolítsa. Ha üresen hagyja, akkor minden alkalommal megnyílik a párbeszédablak az összeg beírásához.</string>
<string name="quick_zap_amounts_explainer">A „Zap gomb megnyomásakor jelenik meg. Az egyes összegeket a címzett által támogatott bármely fizetési csatornán keresztül ki lehet fizetni - Lightning, Cashu vagy láncon belül (láncon belül csak nagyobb összegek esetén). Érintse meg az összeget annak eltávolításához. Ha üresen hagyja, a rendszer minden alkalommal megnyitja az összeg megadására szolgáló párbeszédpanelt.</string>
<string name="send_onchain_instead">Küldés inkább láncon belül</string>
<string name="reload_mint_title">Mint feltöltése</string>
<string name="reload_mint_sats_amount">%1$s sat</string>
<string name="reload_mint_topup_label">Feltöltés összege</string>
<string name="reload_mint_section_to">Feltöltendő mint</string>
<string name="reload_mint_section_from">Fedezet innen</string>
<string name="reload_mint_pay_lightning">Kifizetés Lightninggal</string>
<string name="reload_mint_lightning_desc">Új ecash verése a saját Lightning tárcából</string>
<string name="reload_mint_not_enough">Itt nincs elég fedezet</string>
<string name="reload_mint_available">%1$s sat érhető ek</string>
<string name="reload_mint_confirm">Feltöltés és zap küldése</string>
<string name="reload_mint_send_confirm">Zap küldése</string>
<string name="reload_mint_awaiting_payment">Várakozás a fizetésre…</string>
<string name="reload_mint_retry">Próbálja újra</string>
<string name="reload_mint_summary"> %1$s sat feltöltése ide: %2$s és %3$s zapelése %4$s sattal · díj ≈ %5$s sat</string>
<string name="reload_mint_summary_funded">%1$s zapelése %2$s sattal innen: %3$s</string>
<string name="reload_mint_recipient_fallback">a címzett</string>
<string name="reload_mint_needs_more">további %1$s sat szükséges</string>
<string name="reload_mint_funded">feltöltve</string>
<string name="reload_mint_copy_invoice">Számla másolása</string>
<string name="topup_mint_title">Mint feltöltése</string>
<string name="topup_mint_action">Ezen mint feltöltése</string>
<string name="topup_mint_amount_label">Hozzáadandó összeg</string>
<string name="topup_mint_confirm">Feltöltés</string>
<string name="zap_privacy_section">Zap adatvédelem</string>
<string name="zap_type_section_explainer">Állítsa be, hogy hogyan jelenjen meg a személyazonossága a zap elküldésekor.</string>
<string name="wallet_connect_connect_app">Pénztárca összekapcsolása</string>
@@ -1345,6 +1374,8 @@
<string name="account_settings">Fiókbeállítások</string>
<string name="app_settings">Alkalmazásbeállítások</string>
<!-- Settings search -->
<string name="settings_search_placeholder">Keresési beállítások</string>
<string name="settings_search_no_results">Nincs találat a beállításokban erre: „%1$s”</string>
<!-- Settings search keywords: an English concept/protocol index that supplements the
(translated) row titles. Marked translatable="false" so protocol terms (blossom, nsec,
negentropy, …) stay searchable in every locale and aren't sent to translators. -->
@@ -1476,6 +1507,9 @@
<string name="copy_nprofile_to_clipboard">nprofile-kulcs vágólapra másolása</string>
<string name="copy_npub_to_clipboard">Npub másolása a vágólapra</string>
<string name="share_or_save">Megosztás vagy mentés</string>
<string name="share_target_as_dm">Küldés közvetlen üzenetként</string>
<string name="share_to_dm_title">Küldés ide…</string>
<string name="share_to_dm_start_new">Új üzenet</string>
<string name="copy_url_to_clipboard">Webcím másolása a vágólapra</string>
<string name="copy_the_note_id_to_the_clipboard">Bejegyzés-azonosító másolása a vágólapra</string>
<string name="add_media_to_gallery">Média hozzáadása a galériához</string>
@@ -2853,6 +2853,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">Ta zbiórka została zamknięta</string>
<string name="goal_progress">Sfinansowano %1$s z %2$s satoszów</string>
<string name="fundraiser_ends">Kończy się %1$s</string>
<string name="fundraiser_onchain_donation">Darowizna on-chain</string>
<string name="goal_amount_label">Kwota zbiorki (w satoszach)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Opisz cel zbiórki</string>
@@ -8,6 +8,12 @@
<string name="show_anyway">Показать</string>
<string name="post_was_hidden">Данный пост был скрыт, потому что упоминает скрытых ваши пользователей</string>
<string name="post_was_flagged_as_inappropriate_by">Запись была помечена как неуместная</string>
<plurals name="post_was_hidden_due_to_too_many_hashtags">
<item quantity="one">Этот пост имеет более чем %1$d хэштег</item>
<item quantity="few">Этот пост имеет более чем %1$d хэштегов</item>
<item quantity="many">Этот пост имеет более чем %1$d хэштегов</item>
<item quantity="other">Этот пост имеет более чем %1$d хэштегов</item>
</plurals>
<string name="post_not_found">запись не найдена</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Фото канала</string>
@@ -15,6 +21,7 @@
<string name="could_not_decrypt_the_message">Не удалось расшифровать сообщение</string>
<string name="group_picture">Фото группы</string>
<string name="explicit_content">Запрещённый контент</string>
<string name="duplicated_post">Дублированный пост</string>
<string name="spam">Спам</string>
<string name="impersonation">Выдача себя за другое лицо</string>
<string name="illegal_behavior">Незаконные действия</string>
@@ -27,6 +34,7 @@
<string name="copy_text">Копировать текст</string>
<string name="copy_user_pubkey">Копировать ID автора</string>
<string name="copy_note_id">Копировать ID записи</string>
<string name="copy_raw_json">Скопировать исходный JSON</string>
<string name="broadcast">Разослать</string>
<string name="request_deletion">Запросить удаление</string>
<string name="block_report">Блок / Жалоба</string>
@@ -44,6 +52,7 @@
<string name="login_with_a_private_key_to_like_posts">Вы используете публичный ключ, они - только для чтения. Войдите с приватным ключом, чтобы лайкать посты</string>
<string name="no_zap_amount_setup_long_press_to_change">Не настроены запы. Нажмите и удерживайте для настройки</string>
<string name="chat_zap_anonymous">Анонимный</string>
<string name="chat_raid_is_raiding">проводит рейды</string>
<string name="chat_clip_created_a_clip">создал клип</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Войдите с приватным ключом чтобы запать</string>
<string name="login_with_a_private_key_to_be_able_to_follow">Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность подписаться</string>
@@ -52,6 +61,7 @@
<string name="login_with_a_private_key_to_be_able_to_show_word">Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность показать слово или предложение</string>
<string name="login_with_a_private_key_to_be_able_to_change_settings">Вы используете публичный ключ. Войдите приватным ключом, чтобы редактировать</string>
<string name="login_with_a_private_key_to_be_able_to_upload">Вы используете публичный ключ, они - только для чтения. Войдите в систему приватным ключом, чтобы иметь возможность загружать</string>
<string name="login_with_a_private_key_to_be_able_to_sign_events">Вы используете публичный ключ. Войдите приватным ключом, чтобы редактировать</string>
<string name="unauthorized_exception_description">Подписчик не авторизовал расшифровку, необходимую для выполнения этой операции. Активируйте расшифровки NIP-44 в приложении для подписчика и попробуйте еще раз</string>
<string name="signer_not_found_exception">Подпись не найдена</string>
<string name="zaps">Запы</string>
@@ -85,6 +95,7 @@
<string name="thank_you_so_much">Большое спасибо!</string>
<string name="amount_in_sats">Сумма в sat</string>
<string name="send_sats">Отправить</string>
<string name="secret_note_to_receiver">Секретное примечание для получателя</string>
<string name="secret_note_to_receiver_placeholder">Мое скрытое сообщение</string>
<string name="secret_visible_text">Видимый префикс</string>
<string name="secret_add_to_text">Добавить к посту</string>
@@ -103,6 +114,7 @@
<string name="write_a_message">Написать сообщение…</string>
<string name="post">Отправить</string>
<string name="save">Сохранить</string>
<string name="send">Отправить</string>
<string name="create">Создать</string>
<string name="rename">Переименовать</string>
<string name="cancel">Отменить</string>
@@ -138,6 +150,7 @@
<string name="pronouns">Местоимения</string>
<string name="ln_address">LN адрес</string>
<string name="ln_url_outdated">LN URL (устаревш.)</string>
<string name="stale_relay_hint_label">Ретрансляторы могут быть устаревшими</string>
<string name="download_to_phone">Сохранить в телефон</string>
<string name="save_to_gallery">Сохранить в галерею</string>
<string name="image_saved_to_the_gallery">Фото сохранено в галерею</string>
@@ -146,6 +159,7 @@
<string name="failed_to_save_the_image">Не удалось сохранить фото</string>
<string name="video_saved_to_the_gallery">Видео сохранено в галерею</string>
<string name="failed_to_save_the_video">Не удалось сохранить видео</string>
<string name="failed_to_save_the_pdf">Не удалось сохранить PDF-файл</string>
<string name="upload_image">Загрузить фото</string>
<string name="upload_file">Загрузить файл</string>
<string name="take_a_picture">Сделать фото</string>
@@ -155,13 +169,16 @@
<string name="record_a_message_description">Нажмите и удерживайте, чтобы записать сообщение</string>
<string name="re_record">Перезаписать</string>
<string name="recording_indicator_description">Запись</string>
<string name="recording_indicator_with_time">Запись %1$s</string>
<string name="uploading">Загрузка…</string>
<string name="upload_error_title">Ошибка загрузки</string>
<string name="upload_error_voice_message_failed">Неудалось загрузить голосовое сообщение</string>
<string name="upload_error_voice_message_unexpected_state">Неожиданное состояние загрузки</string>
<string name="voice_preset_none">Отсутствует</string>
<string name="voice_preset_deep">Глубокий</string>
<string name="voice_preset_high">Высокий</string>
<string name="voice_preset_neutral">Нейтральный</string>
<string name="voice_anonymize_title">Анонимизировать</string>
<string name="voice_anonymize_description">Обрабатывает ваш голос. Внимание: основные изменения тона потенциально могут быть отменены определенными слушателями.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Пользователь не установил Lightning адрес для получения чаевых</string>
<string name="reply_here">"ответить.. "</string>
@@ -174,6 +191,7 @@
<string name="blocked_users">Заблокированные пользователи</string>
<string name="new_threads">Новые треды</string>
<string name="conversations">Обсуждения</string>
<string name="mod_queue">Очередь модов</string>
<string name="notes">Записи</string>
<string name="replies">Ответы</string>
<string name="mutual">Ваш</string>
@@ -226,12 +244,14 @@
<string name="error_loading_replies">"Не удалось загрузить ответы: "</string>
<string name="try_again">Повторить</string>
<string name="notification_feed_is_empty">Уведомлений пока нет.</string>
<string name="open_poll">Открыть опрос</string>
<string name="feed_is_empty">Лента пуста.</string>
<string name="refresh">Обновить</string>
<string name="created">создал(а)</string>
<string name="with_description_of">с описанием</string>
<string name="and_picture">и фото</string>
<string name="changed_chat_name_to">сменил(а) название на</string>
<string name="changed_chat_profile_to">Новый профиль чата:</string>
<string name="description_to">описание на</string>
<string name="and_picture_to">и фото на</string>
<string name="leave">Выйти</string>
@@ -2789,6 +2789,14 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">此目标已关闭</string>
<string name="goal_progress">设定目标为 %2$s sats,筹集到 %1$s</string>
<string name="fundraiser_ends">结束 %1$s</string>
<string name="fundraiser_onchain_donation">链上捐助</string>
<plurals name="birdex_species_count">
<item quantity="other">Birdex · %1$d 个物种</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="other">%1$s + 另%2$d</item>
</plurals>
<string name="goal_amount_label">目标金额 (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">描述您的目标</string>
+21
View File
@@ -1533,6 +1533,7 @@
<string name="favorite_dvms_search_keywords" translatable="false">dvm, data vending machine, algo, algorithm, feeds</string>
<string name="reactions_search_keywords" translatable="false">emoji, like, reaction</string>
<string name="video_player_search_keywords" translatable="false">video, player, playback, autoplay, mute</string>
<string name="audio_visualizer_search_keywords" translatable="false">audio, visualizer, spectrum, bars, waves, radial, aurora, animation</string>
<string name="payment_targets_search_keywords" translatable="false">zap split, split, recipients, forward zaps</string>
<string name="call_settings_search_keywords" translatable="false">webrtc, video call, voice call, calls</string>
<string name="translations_search_keywords" translatable="false">language, translate, locale</string>
@@ -2343,6 +2344,16 @@
<string name="video_player_settings_action_cast">Cast to Device</string>
<string name="video_player_settings_action_cast_description">Send the video to a Chromecast receiver on your Wi-Fi (hidden for local files)</string>
<string name="audio_visualizer_settings">Audio Visualizer</string>
<string name="audio_visualizer_settings_description">Choose the animation shown while audio notes play.</string>
<string name="audio_visualizer_off">Off</string>
<string name="audio_visualizer_bars">Spectrum Bars</string>
<string name="audio_visualizer_waves">Color Waves</string>
<string name="audio_visualizer_radial">Radial Ring</string>
<string name="audio_visualizer_aurora">Aurora Glow</string>
<string name="audio_visualizer_classic">Classic Waveform</string>
<string name="audio_visualizer_static">Static Image</string>
<string name="profile_image_of_user">Profile Picture of %1$s</string>
<string name="relay_info">Relay %1$s</string>
<string name="expand_relay_list">Expand relay list</string>
@@ -3143,6 +3154,16 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">This goal has closed</string>
<string name="goal_progress">%1$s funded of %2$s sats goal</string>
<string name="fundraiser_ends">Ends %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donation</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d species</item>
<item quantity="other">Birdex · %1$d species</item>
</plurals>
<plurals name="birdex_species_preview_more">
<item quantity="one">%1$s +%2$d more</item>
<item quantity="other">%1$s +%2$d more</item>
</plurals>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
@@ -0,0 +1,139 @@
/*
* 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.playback.playerPool
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import kotlinx.coroutines.flow.MutableSharedFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.PI
import kotlin.math.sin
@OptIn(UnstableApi::class)
class SpectrumAudioBufferSinkTest {
private val fftSize = 64
private val binCount = 16
private fun sineShorts(
k: Int,
n: Int,
): ShortArray = ShortArray(n) { (sin(2.0 * PI * k * it / n) * 30000).toInt().toShort() }
private fun monoPcm(samples: ShortArray): ByteBuffer {
val bb = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN)
for (s in samples) bb.putShort(s)
bb.flip()
return bb
}
private fun interleavedStereoPcm(
left: ShortArray,
right: ShortArray,
): ByteBuffer {
val bb = ByteBuffer.allocate(left.size * 4).order(ByteOrder.LITTLE_ENDIAN)
for (i in left.indices) {
bb.putShort(left[i])
bb.putShort(right[i])
}
bb.flip()
return bb
}
private fun maxBin(spectrumBins: FloatArray): Int = spectrumBins.indices.maxByOrNull { spectrumBins[it] } ?: -1
@Test
fun lowFrequencySineLightsLowBins() {
val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount)
val out = MutableSharedFlow<Spectrum>(replay = 1, extraBufferCapacity = 1)
sink.output = out
sink.flush(48000, 1, C.ENCODING_PCM_16BIT)
sink.handleBuffer(monoPcm(sineShorts(k = 2, n = fftSize)))
val bins =
out.replayCache
.last()
.bins
assertEquals(binCount, bins.size)
assertTrue("expected low bin to dominate, got ${maxBin(bins)}", maxBin(bins) < binCount / 2)
}
@Test
fun highFrequencySineLightsHighBins() {
val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount)
val out = MutableSharedFlow<Spectrum>(replay = 1, extraBufferCapacity = 1)
sink.output = out
sink.flush(48000, 1, C.ENCODING_PCM_16BIT)
sink.handleBuffer(monoPcm(sineShorts(k = 28, n = fftSize)))
val bins =
out.replayCache
.last()
.bins
assertTrue("expected high bin to dominate, got ${maxBin(bins)}", maxBin(bins) >= binCount / 2)
}
@Test
fun stereoIsDownmixedFromFirstChannel() {
val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount)
val out = MutableSharedFlow<Spectrum>(replay = 1, extraBufferCapacity = 1)
sink.output = out
sink.flush(48000, 2, C.ENCODING_PCM_16BIT)
sink.handleBuffer(interleavedStereoPcm(sineShorts(k = 2, n = fftSize), ShortArray(fftSize)))
val bins =
out.replayCache
.last()
.bins
assertTrue(maxBin(bins) < binCount / 2)
}
@Test
fun emitsOnlyAfterAFullFftWindowAccumulates() {
val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount)
val out = MutableSharedFlow<Spectrum>(replay = 1, extraBufferCapacity = 1)
sink.output = out
sink.flush(48000, 1, C.ENCODING_PCM_16BIT)
val full = sineShorts(k = 4, n = fftSize)
sink.handleBuffer(monoPcm(full.copyOfRange(0, fftSize / 2)))
assertTrue("no frame should emit before a full window", out.replayCache.isEmpty())
sink.handleBuffer(monoPcm(full.copyOfRange(fftSize / 2, fftSize)))
assertEquals(1, out.replayCache.size)
}
@Test
fun nonPcm16EncodingEmitsNothing() {
val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount)
val out = MutableSharedFlow<Spectrum>(replay = 1, extraBufferCapacity = 1)
sink.output = out
sink.flush(48000, 1, C.ENCODING_PCM_FLOAT)
sink.handleBuffer(monoPcm(sineShorts(k = 4, n = fftSize)))
assertTrue("non-16-bit PCM must not emit a spectrum", out.replayCache.isEmpty())
}
}
@@ -55,7 +55,7 @@
<string name="accessibility_user_avatar">User avatar</string>
<string name="accessibility_navigate">Navigate</string>
<!-- Relay history paging (shared feed markers + status card) -->
<!-- Relay history paging (shared feed markers + status card) -->
<string name="chats_history_relay_sync">Relay sync:</string>
<string name="chats_history_older">Older %1$s messages</string>
<string name="chats_history_all_caught_up">All caught up</string>
@@ -72,4 +72,7 @@
<item quantity="one">%1$d relay</item>
<item quantity="other">%1$d relays</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">replying to </string>
</resources>
@@ -0,0 +1,110 @@
/*
* 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.audio
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.log10
/** One frame of frequency-domain magnitudes, ordered low→high Hz and normalized 0f..1f. */
class Spectrum(
val bins: FloatArray,
)
/**
* Groups a linear-frequency magnitude array (index 0 = DC, last index = Nyquist)
* into [binCount] log-spaced buckets so bass/mid get more bars than the treble
* tail. Output is normalized 0f..1f using [floorDb] as the silence floor.
* Bin 0 (DC) is always excluded; the Nyquist bin (last) is included.
* Low bins are guaranteed to map to distinct FFT bins (contiguous, non-aliased).
*/
fun FloatArray.toLogBins(
binCount: Int,
floorDb: Float = -60f,
): FloatArray {
if (isEmpty() || binCount <= 0) return FloatArray(0)
val out = FloatArray(binCount)
val logSize = ln(size.toDouble())
var lo = 1 // skip DC (index 0)
for (b in 0 until binCount) {
var hi = exp(logSize * (b + 1) / binCount).toInt()
if (hi <= lo) hi = lo + 1 // contiguous & distinct buckets: low bins map to distinct FFT bins, not all to bin 1
if (hi > size) hi = size
var peak = 0f
var i = lo
while (i < hi) {
if (this[i] > peak) peak = this[i]
i++
}
val db = if (peak > 0f) 20f * log10(peak) else floorDb
out[b] = ((db - floorDb) / -floorDb).coerceIn(0f, 1f)
lo = hi
}
return out
}
/**
* Returns a NEW array scaled so the largest value becomes 1f. An all-zero input is returned as a zero-filled copy.
* Reference/allocating variant production uses the *Into / in-place form on the audio thread.
*/
fun FloatArray.normalizedToPeak(): FloatArray {
var peak = 0f
for (v in this) if (v > peak) peak = v
if (peak <= 0f) return copyOf()
val inv = 1f / peak
return FloatArray(size) { this[it] * inv }
}
/**
* In-place version of [normalizedToPeak]: scales this array so its largest value becomes 1f.
* [fromIndex] excludes leading entries from both the peak search and the scaling (e.g. pass 1 to
* ignore the DC bin of an FFT magnitude array, which downstream log-binning also skips).
*/
fun FloatArray.normalizeToPeakInPlace(fromIndex: Int = 0) {
var peak = 0f
for (i in fromIndex until size) if (this[i] > peak) peak = this[i]
if (peak <= 0f) return
val inv = 1f / peak
for (i in fromIndex until size) this[i] *= inv
}
/**
* Holds back the latest [frames] spectra and emits the one from [frames] hops ago. The PCM tap
* computes each [Spectrum] inside the audio processor chain, upstream of the AudioTrack output
* buffer, so it runs ahead of what's audible by roughly that buffer's depth. Delaying the visual by
* the same number of decoded FFT hops (one [Spectrum] == one hop) realigns it with the speaker, in
* the same decoded-sample units as the lead, so it stays correct across startup and rate jitter.
* [frames] <= 0 is identity (e.g. previews, which carry no output latency).
*/
fun Flow<Spectrum>.delayedByFrames(frames: Int): Flow<Spectrum> =
if (frames <= 0) {
this
} else {
flow {
val queue = ArrayDeque<Spectrum>(frames + 1)
collect { frame ->
queue.addLast(frame)
if (queue.size > frames) emit(queue.removeFirst())
}
}
}
@@ -0,0 +1,39 @@
/*
* 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.audio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
/**
* Renders [spectrum] using whichever [style] the user selected.
* CLASSIC is not a spectrum renderer; passing it renders nothing (OffRenderer fallback). Callers handle CLASSIC separately.
*/
@Composable
fun AudioVisualizer(
style: VisualizerStyle,
spectrum: Flow<Spectrum>,
modifier: Modifier = Modifier,
palette: VisualizerPalette = VisualizerPalette.DEFAULT,
) {
VisualizerRegistry.forStyle(style).Render(spectrum, palette, modifier)
}
@@ -0,0 +1,56 @@
/*
* 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.audio
import kotlin.math.PI
import kotlin.math.cos
object AudioWindow {
/** Hann window of length [n]. */
fun hann(n: Int): FloatArray {
if (n <= 1) return FloatArray(n) { 1f }
return FloatArray(n) { (0.5 - 0.5 * cos(2.0 * PI * it / (n - 1))).toFloat() }
}
/**
* Converts 16-bit PCM [samples] to normalized floats in [-1, 1], multiplies by
* [window], and pads/truncates to `window.size`.
*/
fun shortsToWindowed(
samples: ShortArray,
window: FloatArray,
): FloatArray =
FloatArray(window.size) { i ->
if (i < samples.size) (samples[i] / 32768f) * window[i] else 0f
}
/** Like [shortsToWindowed] but writes into [out] (size must equal [window].size); no allocation. */
fun shortsToWindowedInto(
samples: ShortArray,
window: FloatArray,
out: FloatArray,
) {
require(out.size == window.size) { "out buffer must match window size" }
for (i in window.indices) {
out[i] = if (i < samples.size) (samples[i] / 32768f) * window[i] else 0f
}
}
}
@@ -0,0 +1,121 @@
/*
* 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.audio
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Minimal in-place iterative radix-2 CooleyTukey FFT (pure Kotlin, no JNI).
* Adapted from Nayuki's public-domain "Free small FFT". Only power-of-2 sizes
* are supported, which is all an audio visualiser needs (256/512/1024).
*/
object Fft {
/** In-place forward transform. [re]/[im] must be the same power-of-2 length. */
fun transform(
re: DoubleArray,
im: DoubleArray,
) {
val n = re.size
require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" }
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j or bit
if (i < j) {
val tr = re[i]
re[i] = re[j]
re[j] = tr
val ti = im[i]
im[i] = im[j]
im[j] = ti
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * PI / len
val wLenRe = cos(ang)
val wLenIm = sin(ang)
var i = 0
while (i < n) {
var wRe = 1.0
var wIm = 0.0
val half = len / 2
for (k in 0 until half) {
val aRe = re[i + k]
val aIm = im[i + k]
val bRe = re[i + k + half] * wRe - im[i + k + half] * wIm
val bIm = re[i + k + half] * wIm + im[i + k + half] * wRe
re[i + k] = aRe + bRe
im[i + k] = aIm + bIm
re[i + k + half] = aRe - bRe
im[i + k + half] = aIm - bIm
val nextWRe = wRe * wLenRe - wIm * wLenIm
wIm = wRe * wLenIm + wIm * wLenRe
wRe = nextWRe
}
i += len
}
len = len shl 1
}
}
/**
* Forward-transforms real [signal] and returns magnitudes for bins 0..N/2 (DC..Nyquist).
* Reference/allocating variant production uses the *Into / in-place form on the audio thread.
*/
fun magnitudes(signal: FloatArray): FloatArray {
val out = FloatArray(signal.size / 2 + 1)
magnitudesInto(signal, DoubleArray(signal.size), DoubleArray(signal.size), out)
return out
}
/**
* Like [magnitudes] but writes into caller-owned buffers (no allocation). [re] and [im] must be
* the same length as [signal] (a power of 2); [out] must be `signal.size / 2 + 1`. Used on the
* audio thread to avoid per-frame garbage.
*/
fun magnitudesInto(
signal: FloatArray,
re: DoubleArray,
im: DoubleArray,
out: FloatArray,
) {
val n = signal.size
require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" }
require(re.size == n && im.size == n) { "re/im buffers must be size $n" }
require(out.size == n / 2 + 1) { "out buffer must be size ${n / 2 + 1}" }
for (i in 0 until n) {
re[i] = signal[i].toDouble()
im[i] = 0.0
}
transform(re, im)
for (i in out.indices) out[i] = sqrt(re[i] * re[i] + im[i] * im[i]).toFloat()
}
}
@@ -0,0 +1,80 @@
/*
* 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.audio
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.DrawScope
import kotlinx.coroutines.flow.Flow
/**
* Collects [spectrum] into a decayed [FloatArray] and optionally runs a monotonic time clock,
* then calls [draw] inside the Canvas draw lambda. The fast-changing state is read
* ONLY in the draw lambda, so new frames trigger the draw phase, never recomposition.
* Pass [animated] = false for non-time-varying styles (bars, radial) to avoid 60fps redraws.
*/
@Composable
fun SpectrumCanvas(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
decay: Float = 0.85f,
animated: Boolean = true,
draw: DrawScope.(bins: FloatArray, timeSec: Float, palette: VisualizerPalette) -> Unit,
) {
val smoothed = remember { mutableStateOf(FloatArray(0)) }
LaunchedEffect(spectrum, decay) {
var prev = FloatArray(0)
spectrum.collect { frame ->
// A fresh array each frame is intentional: mutableStateOf compares by reference, so a new
// instance is what signals Compose to redraw. Do NOT switch to in-place mutation.
val next =
FloatArray(frame.bins.size) { i ->
val prior = if (i < prev.size) prev[i] * decay else 0f
if (frame.bins[i] > prior) frame.bins[i] else prior
}
smoothed.value = next
prev = next
}
}
val timeSec = remember { mutableStateOf(0f) }
if (animated) {
LaunchedEffect(Unit) {
var last = 0L
while (true) {
withFrameMillis { ms ->
if (last != 0L) timeSec.value += (ms - last) / 1000f
last = ms
}
}
}
}
Canvas(modifier) {
draw(smoothed.value, if (animated) timeSec.value else 0f, palette)
}
}
@@ -0,0 +1,67 @@
/*
* 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.audio
import androidx.compose.runtime.withFrameMillis
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sin
/** Deterministic decorative spectrum for previews and demos (no real audio). */
object SyntheticSpectrum {
fun frame(
timeSec: Float,
binCount: Int,
): Spectrum {
val t = timeSec
val bins =
FloatArray(binCount) { i ->
val f = i / binCount.toFloat()
val beat = (0.5 + 0.5 * sin(t * 2.0 * PI * 1.9)).pow(6.0)
val bass = exp((-f * 9.0)) * (0.55 + 0.9 * beat)
val mid = exp(-((f - 0.35) * 4.0).pow(2.0)) * (0.35 + 0.25 * sin(t * 5.0 + i))
val treble = exp(-((f - 0.8) * 3.5).pow(2.0)) * max(0.0, sin(t * 14.0 + i * 2.3)) * 0.4
val v = bass + mid + treble + 0.04 * sin(t * 3.0 + i * 0.7)
v.coerceIn(0.0, 1.0).toFloat()
}
return Spectrum(bins)
}
/**
* Compose-friendly stream that emits one [frame] per display frame.
*
* Must be collected inside a Compose frame-clock scope (e.g. a `LaunchedEffect`); it uses
* `withFrameMillis`, which throws if collected from a plain coroutine with no frame clock.
*/
fun flow(binCount: Int = 48): Flow<Spectrum> =
flow {
var startMs = -1L
while (true) {
val ms = withFrameMillis { it }
if (startMs < 0) startMs = ms
emit(frame((ms - startMs) / 1000f, binCount))
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.audio
import androidx.compose.runtime.Immutable
/**
* Hue anchors (degrees, 0..360) the renderers use to colour the spectrum. Defaults
* give the cyanmagenta / bass-purple / treble-pink look from the design mockups.
*/
@Immutable
data class VisualizerPalette(
val lowHue: Float = 280f,
val midHue: Float = 200f,
val highHue: Float = 330f,
val saturation: Float = 0.92f,
val lightness: Float = 0.6f,
) {
init {
require(saturation in 0f..1f) { "saturation must be in 0f..1f, was $saturation" }
require(lightness in 0f..1f) { "lightness must be in 0f..1f, was $lightness" }
}
companion object {
val DEFAULT = VisualizerPalette()
}
}
/** Wraps a hue into 0f..360f (handles negatives), for Color.hsl. */
fun Float.wrapHue(): Float = ((this % 360f) + 360f) % 360f
@@ -0,0 +1,41 @@
/*
* 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.audio
import com.vitorpamplona.amethyst.commons.audio.renderers.AuroraRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.BarsRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.OffRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.RadialRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.StaticRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.WavesRenderer
/**
* CLASSIC is intentionally absent it requires the live player and is handled by the caller;
* forStyle(CLASSIC) falls back to OffRenderer.
*/
object VisualizerRegistry {
val all: List<VisualizerRenderer> =
listOf(OffRenderer, BarsRenderer, WavesRenderer, RadialRenderer, AuroraRenderer, StaticRenderer)
private val byStyle = all.associateBy { it.style }
fun forStyle(style: VisualizerStyle): VisualizerRenderer = byStyle[style] ?: OffRenderer
}
@@ -0,0 +1,37 @@
/*
* 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.audio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
/** A single visualiser style. Implementations draw [spectrum] frames onto a Canvas. */
interface VisualizerRenderer {
val style: VisualizerStyle
@Composable
fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
)
}
@@ -0,0 +1,38 @@
/*
* 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.audio
enum class VisualizerStyle {
CLASSIC,
BARS,
WAVES,
RADIAL,
AURORA,
STATIC,
OFF,
;
companion object {
val DEFAULT = CLASSIC
fun fromName(name: String?): VisualizerStyle = entries.firstOrNull { it.name == name } ?: DEFAULT
}
}
@@ -0,0 +1,128 @@
/*
* 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.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.sin
/**
* Three overlapping glowing ribbons. Each ribbon is a stroked line whose vertical displacement from
* its centre is the live spectrum of its band (interpolated across the width), so the ribbon's shape
* tracks the audio; the glow thickness swells with the overall energy. A small time shimmer (±8%)
* adds life without driving the motion. The line is clamped inside the canvas (accounting for the
* stroke width) so the tops/bottoms never get cut.
*/
object AuroraRenderer : VisualizerRenderer {
override val style = VisualizerStyle.AURORA
private class Ribbon(
val hue: (VisualizerPalette) -> Float,
val yo: Float,
val lo: Float,
val hi: Float,
val dir: Float,
)
private val ribbons =
listOf(
Ribbon({ it.midHue - 40f }, 0.55f, 0f, 0.5f, -1f),
Ribbon({ it.lowHue }, 0.5f, 0.15f, 0.85f, 1f),
Ribbon({ it.highHue }, 0.45f, 0.5f, 1f, -1f),
)
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
val paths = remember { List(ribbons.size) { Path() } }
// Each ribbon's gradient depends only on the palette (the animation lives in the Path
// geometry), so build the brushes once instead of allocating three Colors + a Brush per
// ribbon on every frame.
val brushes =
remember(palette) {
ribbons.map { ribbon ->
val hue = ribbon.hue(palette).wrapHue()
Brush.horizontalGradient(
0f to Color.hsl(hue, palette.saturation, palette.lightness, 0f),
0.5f to Color.hsl(hue, palette.saturation, palette.lightness, 0.6f),
1f to Color.hsl(hue, palette.saturation, palette.lightness, 0f),
)
}
}
SpectrumCanvas(spectrum, palette, modifier) { bins, t, _ ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val w = size.width
val h = size.height
// overall energy → glow thickness swells with loudness (bounded)
var energy = 0f
for (b in bins) energy += b
energy /= bins.size
val strokeW = 12f + energy * 30f
val half = strokeW / 2f
ribbons.forEachIndexed { index, r ->
val path = paths[index]
path.reset()
var x = 0f
while (x <= w) {
val f = x / w
// interpolate this ribbon's band → the displacement IS the spectrum
val fb = (r.lo + (r.hi - r.lo) * f) * (n - 1)
val i0 = fb.toInt().coerceIn(0, n - 1)
val i1 = (i0 + 1).coerceAtMost(n - 1)
val v = bins[i0] + (bins[i1] - bins[i0]) * (fb - i0)
val shimmer = 1f + 0.08f * sin(f * 9f + t)
val disp = (v * shimmer).coerceIn(0f, 1f) * h * 0.40f
// maxOf guards the reversed-range crash when the canvas is shorter than the
// loudness-swelled stroke (h < strokeW makes half > h - half).
val y = (h * r.yo + r.dir * disp).coerceIn(half, maxOf(half, h - half))
if (x == 0f) path.moveTo(x, y) else path.lineTo(x, y)
x += 5f
}
drawPath(
path = path,
brush = brushes[index],
style = Stroke(width = strokeW, cap = StrokeCap.Round),
blendMode = BlendMode.Plus,
)
}
}
}
}
@@ -0,0 +1,69 @@
/*
* 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.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.min
object BarsRenderer : VisualizerRenderer {
override val style = VisualizerStyle.BARS
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
SpectrumCanvas(spectrum, palette, modifier, animated = false) { bins, _, pal ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val gap = 2f
val barW = (size.width - gap * (n - 1)) / n
if (barW <= 0f) return@SpectrumCanvas
val mid = size.height / 2f
val radius = CornerRadius(min(barW / 2f, 3f), min(barW / 2f, 3f))
for (i in 0 until n) {
val v = bins[i].coerceIn(0f, 1f)
val half = v * (size.height / 2f - 2f)
if (half <= 0f) continue
val hue = pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat())
drawRoundRect(
color = Color.hsl(hue.wrapHue(), pal.saturation, pal.lightness),
topLeft = Offset(i * (barW + gap), mid - half),
size = Size(barW, half * 2f),
cornerRadius = radius,
)
}
}
}
}
@@ -0,0 +1,44 @@
/*
* 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.audio.renderers
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import kotlinx.coroutines.flow.Flow
object OffRenderer : VisualizerRenderer {
override val style = VisualizerStyle.OFF
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
// Draws nothing, but honors the modifier so the reserved layout region doesn't collapse.
Box(modifier)
}
}
@@ -0,0 +1,93 @@
/*
* 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.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
object RadialRenderer : VisualizerRenderer {
override val style = VisualizerStyle.RADIAL
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
SpectrumCanvas(spectrum, palette, modifier, animated = false) { bins, _, pal ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val cx = size.width / 2f
val cy = size.height / 2f
val minDim = min(size.width, size.height)
// A zero/near-zero canvas (transient layout pass while audio plays) would make coreR == 0,
// and Brush.radialGradient(radius = 0f) throws. Nothing meaningful to draw at that size.
if (minDim <= 0f) return@SpectrumCanvas
val r0 = minDim * 0.16f
var energy = 0f
val lowCount = min(8, n)
for (i in 0 until lowCount) energy += bins[i]
energy /= lowCount
val coreR = r0 * (1.4f + energy)
drawCircle(
brush =
Brush.radialGradient(
0f to Color.hsl(pal.highHue, pal.saturation, 0.7f, 0.9f),
1f to Color.hsl(pal.highHue, pal.saturation, 0.6f, 0f),
center = Offset(cx, cy),
radius = coreR,
),
radius = coreR,
center = Offset(cx, cy),
)
for (i in 0 until n) {
val a = i / n.toFloat() * (2f * PI.toFloat()) - PI.toFloat() / 2f
val len = r0 + bins[i].coerceIn(0f, 1f) * minDim * 0.32f
val hue = (pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat())).wrapHue()
drawLine(
color = Color.hsl(hue, pal.saturation, pal.lightness),
start = Offset(cx + cos(a) * r0, cy + sin(a) * r0),
end = Offset(cx + cos(a) * len, cy + sin(a) * len),
strokeWidth = 2.4f,
cap = StrokeCap.Round,
)
}
}
}
}
@@ -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.commons.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SyntheticSpectrum
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* A still, non-animated bar graphic for users who dislike motion. Ignores the live [spectrum]
* and renders a single frozen frame via [BarsRenderer] (which runs its canvas with animated=false,
* so there is no per-frame redraw once settled).
*/
object StaticRenderer : VisualizerRenderer {
override val style = VisualizerStyle.STATIC
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
val frozen = remember { flowOf(SyntheticSpectrum.frame(0f, 48)) }
BarsRenderer.Render(frozen, palette, modifier)
}
}
@@ -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.commons.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.sin
/**
* Three translucent, overlapping filled waves. Each wave's OUTLINE is the live spectrum of its
* frequency band (low / mid / high), interpolated across the width, so the shape tracks the audio
* directly. A small time shimmer (±6%) adds life without driving the motion, and the height is
* clamped to 0.92·h so the fill never reaches or leaves the top edge.
*/
object WavesRenderer : VisualizerRenderer {
override val style = VisualizerStyle.WAVES
private class Layer(
val lo: Float,
val hi: Float,
val hue: (VisualizerPalette) -> Float,
val amp: Float,
val phase: Float,
)
private val layers =
listOf(
Layer(0f, 0.4f, { it.lowHue }, 1.0f, 0f),
Layer(0.2f, 0.75f, { it.midHue }, 0.9f, 1.5f),
Layer(0.5f, 1f, { it.highHue }, 0.8f, 3f),
)
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
val paths = remember { List(layers.size) { Path() } }
// Each layer's gradient depends only on the palette (the animation lives in the Path
// geometry), so build the brushes once instead of allocating two Colors + a Brush per layer
// on every frame.
val brushes =
remember(palette) {
layers.map { layer ->
val hue = layer.hue(palette).wrapHue()
Brush.verticalGradient(
0f to Color.hsl(hue, palette.saturation, palette.lightness, 0.85f),
1f to Color.hsl(hue, palette.saturation, palette.lightness * 0.8f, 0.05f),
)
}
}
SpectrumCanvas(spectrum, palette, modifier) { bins, t, _ ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val w = size.width
val h = size.height
layers.forEachIndexed { index, layer ->
val path = paths[index]
path.reset()
path.moveTo(0f, h)
var x = 0f
while (x <= w) {
val f = x / w
// interpolate the spectrum within this layer's band → smooth, audio-driven outline
val fb = (layer.lo + (layer.hi - layer.lo) * f) * (n - 1)
val i0 = fb.toInt().coerceIn(0, n - 1)
val i1 = (i0 + 1).coerceAtMost(n - 1)
val v = (bins[i0] + (bins[i1] - bins[i0]) * (fb - i0)) * layer.amp
val shimmer = 1f + 0.06f * sin(f * 12f + layer.phase + t * 1.5f)
val height = (v * shimmer).coerceIn(0f, 1f) * h * 0.92f
path.lineTo(x, h - height)
x += 4f
}
path.lineTo(w, h)
path.close()
drawPath(
path = path,
brush = brushes[index],
blendMode = BlendMode.Plus,
)
}
}
}
}
@@ -0,0 +1,55 @@
/*
* 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.ui.feeds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
/**
* A repost (kind 6 / kind 16) wraps another event. When that inner kind has no
* typed Quartz class e.g. a Ditto kind-16767 profile theme wrapped in a
* generic repost Amethyst can neither parse nor render it, so the repost would
* show as a permanently blank card. Feeds use this predicate in their acceptance
* allow-list to drop such reposts, mirroring how regular unknown-kind events are
* never displayed (no UI component renders a bare [Event]).
*
* Returns true only for reposts whose boosted content is displayable, so it can
* replace the `is RepostEvent || is GenericRepostEvent` clause in a feed's
* acceptance allow-list. Non-reposts return false (they are admitted by the
* other clauses).
*
* Conservative: a repost that declares no boosted `k` kind is assumed renderable
* we only hide when we can positively prove the inner kind is unknown.
*
* The `returns(true) implies non-null` contract lets it stand in for the two
* `is` checks in an allow-list without losing the chain's non-null smart-cast
* (the `&& filterParams.match(noteEvent, )` tail relies on it).
*/
@OptIn(ExperimentalContracts::class)
fun Event?.isRenderableRepost(): Boolean {
contract { returns(true) implies (this@isRenderableRepost != null) }
if (this !is BaseRepostEvent) return false
val boostedKind = boostedKind()
return boostedKind == null || EventFactory.isKnownKind(boostedKind)
}
@@ -0,0 +1,55 @@
/*
* 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.ui.note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
data class ReplyContext(
val parentNoteId: String?,
val parentAuthorPubKey: String,
val parentAuthorDisplay: String,
) {
companion object {
fun from(
event: BaseThreadedEvent,
cache: ICacheProvider?,
): ReplyContext? {
val raw = event.replyingToAddressOrEvent() ?: return null
val isAddressable = raw.contains(":")
val parentNoteId = if (isAddressable) null else raw
val parentAuthorPubKey =
when (event) {
is CommentEvent -> event.replyAuthor()?.pubKey
else -> null
} ?: parentNoteId?.let { cache?.getNoteIfExists(it)?.author?.pubkeyHex }
?: return null
val parentAuthorDisplay =
cache?.getUserIfExists(parentAuthorPubKey)?.toBestDisplayName()
?: (parentAuthorPubKey.take(8) + "")
return ReplyContext(parentNoteId, parentAuthorPubKey, parentAuthorDisplay)
}
}
}
@@ -0,0 +1,55 @@
/*
* 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.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.replying_to
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyToLabel(
parentAuthorDisplay: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(modifier = modifier) {
Text(
text = stringResource(Res.string.replying_to),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "@$parentAuthorDisplay",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onClick),
)
}
}
@@ -0,0 +1,65 @@
/*
* 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.audio
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertTrue
class AudioSpectrumTest {
@Test
fun normalizedToPeakScalesMaxToOne() {
val out = floatArrayOf(0f, 2f, 4f, 1f).normalizedToPeak()
assertEquals(1f, out[2], 1e-6f)
assertEquals(0.5f, out[1], 1e-6f)
}
@Test
fun normalizedToPeakHandlesAllZero() {
val out = floatArrayOf(0f, 0f, 0f).normalizedToPeak()
assertTrue(out.all { it == 0f })
}
@Test
fun normalizedToPeakReturnsNewArrayEvenForAllZero() {
val input = floatArrayOf(0f, 0f, 0f)
assertNotSame(input, input.normalizedToPeak())
}
@Test
fun toLogBinsProducesRequestedCountClampedZeroToOne() {
val mags = FloatArray(256) { if (it < 4) 1f else 0f }
val bins = mags.toLogBins(32)
assertEquals(32, bins.size)
assertTrue(bins.all { it in 0f..1f })
assertTrue(bins.first() > bins.last())
}
@Test
fun normalizeToPeakInPlaceMatchesAllocatingVersion() {
val input = floatArrayOf(0f, 2f, 4f, 1f)
val expected = input.normalizedToPeak()
val inPlace = input.copyOf()
inPlace.normalizeToPeakInPlace()
for (i in inPlace.indices) assertEquals(expected[i], inPlace[i], 1e-6f)
}
}
@@ -0,0 +1,56 @@
/*
* 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.audio
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AudioWindowTest {
@Test
fun hannIsZeroAtEndsAndOneInMiddle() {
val w = AudioWindow.hann(9)
assertEquals(0f, w.first(), 1e-5f)
assertEquals(0f, w.last(), 1e-5f)
assertEquals(1f, w[4], 1e-4f)
}
@Test
fun shortsToWindowedNormalizesAndPadsToWindowLength() {
val window = FloatArray(4) { 1f }
val out = AudioWindow.shortsToWindowed(shortArrayOf(32767, -32768), window)
assertEquals(4, out.size)
assertTrue(out[0] in 0.99f..1.01f)
assertTrue(out[1] in -1.01f..-0.99f)
assertEquals(0f, out[2])
assertEquals(0f, out[3])
}
@Test
fun shortsToWindowedIntoMatchesAllocatingVersion() {
val window = AudioWindow.hann(8)
val samples = ShortArray(8) { (it * 1000).toShort() }
val expected = AudioWindow.shortsToWindowed(samples, window)
val out = FloatArray(8)
AudioWindow.shortsToWindowedInto(samples, window, out)
for (i in out.indices) assertEquals(expected[i], out[i], 1e-6f)
}
}
@@ -0,0 +1,74 @@
/*
* 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.audio
import kotlin.math.PI
import kotlin.math.sin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FftTest {
@Test
fun sineProducesPeakAtItsBin() {
val n = 64
val k = 8
val signal = FloatArray(n) { sin(2.0 * PI * k * it / n).toFloat() }
val mags = Fft.magnitudes(signal)
assertEquals(n / 2 + 1, mags.size)
val peakBin = mags.indices.maxByOrNull { mags[it] }
assertEquals(k, peakBin)
}
@Test
fun dcOffsetLandsInBinZero() {
val n = 32
val signal = FloatArray(n) { 0.5f }
val mags = Fft.magnitudes(signal)
val peakBin = mags.indices.maxByOrNull { mags[it] }
assertEquals(0, peakBin)
}
@Test
fun rejectsNonPowerOfTwo() {
var threw = false
try {
Fft.magnitudes(FloatArray(30))
} catch (e: IllegalArgumentException) {
threw = true
}
assertTrue(threw)
}
@Test
fun magnitudesIntoMatchesMagnitudes() {
val n = 64
val signal = FloatArray(n) { sin(2.0 * PI * 5 * it / n).toFloat() }
val expected = Fft.magnitudes(signal)
val out = FloatArray(n / 2 + 1)
Fft.magnitudesInto(signal, DoubleArray(n), DoubleArray(n), out)
for (i in out.indices) assertEquals(expected[i], out[i], 1e-3f)
}
}
@@ -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.audio
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SpectrumDelayTest {
private fun frames(n: Int) = (0 until n).map { Spectrum(floatArrayOf(it.toFloat())) }
@Test
fun delaysByFrameCountHoldingBackTheLatest() =
runTest {
// 10 frames in, hold back the latest 3 → 7 emitted, each shifted 3 hops earlier.
val out = frames(10).asFlow().delayedByFrames(3).toList()
assertEquals(7, out.size)
assertEquals(0f, out.first().bins[0])
assertEquals(6f, out.last().bins[0])
}
@Test
fun zeroDelayIsIdentity() =
runTest {
val out = frames(4).asFlow().delayedByFrames(0).toList()
assertEquals(listOf(0f, 1f, 2f, 3f), out.map { it.bins[0] })
}
@Test
fun negativeDelayIsIdentity() =
runTest {
assertEquals(
4,
frames(4)
.asFlow()
.delayedByFrames(-5)
.toList()
.size,
)
}
@Test
fun fewerFramesThanDelayEmitsNothing() =
runTest {
// Everything is still "in the buffer" (not yet audible), so nothing is shown.
assertEquals(
0,
frames(2)
.asFlow()
.delayedByFrames(5)
.toList()
.size,
)
}
}
@@ -0,0 +1,55 @@
/*
* 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.audio
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SyntheticSpectrumTest {
@Test
fun frameHasRequestedBinsClampedZeroToOne() {
val frame = SyntheticSpectrum.frame(timeSec = 1.0f, binCount = 48)
assertEquals(48, frame.bins.size)
assertTrue(frame.bins.all { it in 0f..1f })
}
@Test
fun frameIsDeterministic() {
val a = SyntheticSpectrum.frame(2.0f, 32).bins
val b = SyntheticSpectrum.frame(2.0f, 32).bins
assertTrue(a.contentEquals(b))
}
@Test
fun bassBinsCarryMoreEnergyThanTreble() {
var low = 0f
var high = 0f
var t = 0f
repeat(20) {
val bins = SyntheticSpectrum.frame(t, 32).bins
low += bins.take(6).sum()
high += bins.takeLast(6).sum()
t += 0.05f
}
assertTrue(low > high)
}
}
@@ -0,0 +1,52 @@
/*
* 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.audio
import kotlin.test.Test
import kotlin.test.assertEquals
class VisualizerRegistryTest {
@Test
fun registryCoversSpectrumStylesExactlyOnce() {
val styles = VisualizerRegistry.all.map { it.style }.toSet()
assertEquals(
setOf(
VisualizerStyle.OFF,
VisualizerStyle.BARS,
VisualizerStyle.WAVES,
VisualizerStyle.RADIAL,
VisualizerStyle.AURORA,
VisualizerStyle.STATIC,
),
styles,
)
assertEquals(6, VisualizerRegistry.all.size)
}
@Test
fun forStyleMatchesForRegisteredStylesAndFallsBackForClassic() {
for (renderer in VisualizerRegistry.all) {
assertEquals(renderer.style, VisualizerRegistry.forStyle(renderer.style).style)
}
// CLASSIC has no spectrum renderer; the dispatcher falls back to OFF.
assertEquals(VisualizerStyle.OFF, VisualizerRegistry.forStyle(VisualizerStyle.CLASSIC).style)
}
}
@@ -0,0 +1,39 @@
/*
* 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.audio
import kotlin.test.Test
import kotlin.test.assertEquals
class VisualizerStyleTest {
@Test
fun parsesByNameWithSafeDefault() {
assertEquals(VisualizerStyle.WAVES, VisualizerStyle.fromName("WAVES"))
assertEquals(VisualizerStyle.RADIAL, VisualizerStyle.fromName("RADIAL"))
assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.fromName("nonsense"))
assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.fromName(null))
}
@Test
fun defaultIsClassic() {
assertEquals(VisualizerStyle.CLASSIC, VisualizerStyle.DEFAULT)
}
}
@@ -0,0 +1,79 @@
/*
* 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.ui.feeds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RepostRenderabilityTest {
private val id = "00".repeat(32)
private val pubKey = "11".repeat(32)
private val sig = "22".repeat(64)
private val createdAt = 1_700_000_000L
private val boostedEventId = "33".repeat(32)
private fun genericRepost(tags: Array<Array<String>>) = GenericRepostEvent(id, pubKey, createdAt, tags, "", sig)
private fun repost(tags: Array<Array<String>>) = RepostEvent(id, pubKey, createdAt, tags, "", sig)
@Test
fun hidesGenericRepostOfUnknownKind() {
// kind 16767 (Ditto profile theme) has no Quartz class → cannot render → hide.
val event = genericRepost(arrayOf(arrayOf("e", boostedEventId), arrayOf("k", "16767")))
assertFalse(event.isRenderableRepost())
}
@Test
fun showsGenericRepostOfKnownKind() {
val event = genericRepost(arrayOf(arrayOf("e", boostedEventId), arrayOf("k", "1")))
assertTrue(event.isRenderableRepost())
}
@Test
fun showsGenericRepostWithoutKindTag() {
// Conservative: with no `k` tag we cannot prove the inner kind is unknown, so keep it.
val event = genericRepost(arrayOf(arrayOf("e", boostedEventId)))
assertTrue(event.isRenderableRepost())
}
@Test
fun hidesKind6RepostOfUnknownKind() {
val event = repost(arrayOf(arrayOf("e", boostedEventId), arrayOf("k", "16767")))
assertFalse(event.isRenderableRepost())
}
@Test
fun nonRepostReturnsFalse() {
val textNote: Event = EventFactory.create(id, pubKey, createdAt, 1, emptyArray(), "", sig)
assertFalse(textNote.isRenderableRepost())
}
@Test
fun nullReturnsFalse() {
val nothing: Event? = null
assertFalse(nothing.isRenderableRepost())
}
}
@@ -0,0 +1,134 @@
/*
* 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.ui.note
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ReplyContextTest {
private val parentEventId = "b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c"
private val parentAuthorPubKey = "4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0"
/** Non-reply text note. Just content, no e/a tags. */
@Test
fun nonReplyReturnsNull() {
val event = TextNoteEvent("", "", 0, emptyArray(), "hello world", "")
val ctx = ReplyContext.from(event, null)
assertNull(ctx)
}
/**
* NIP-10 reply with a marked "reply" e-tag. The author can't come from
* a CommentEvent (kind 1 isn't one), so it must come from the cache's
* parent-note author lookup.
*/
@Test
fun markedReplyResolvesParentAuthorViaCache() {
val event =
TextNoteEvent(
"",
"",
0,
arrayOf(
arrayOf("e", parentEventId, "", "reply"),
arrayOf("p", parentAuthorPubKey),
),
"agreed",
"",
)
val parentUser = User(parentAuthorPubKey) { addr -> Note(addr.toValue()) }
val parentNote = Note(parentEventId).apply { author = parentUser }
val cache = StubCache(notesById = mapOf(parentEventId to parentNote))
val ctx = ReplyContext.from(event, cache)
assertEquals(parentEventId, ctx?.parentNoteId)
assertEquals(parentAuthorPubKey, ctx?.parentAuthorPubKey)
// No user metadata loaded — display falls back to truncated hex + ellipsis.
assertTrue(ctx?.parentAuthorDisplay?.endsWith("") == true)
}
/**
* Reply with the parent NOT in the cache and no NIP-22 replyAuthor() tag.
* from() can't resolve an author, so it bails out (returns null).
* Recomposition picks up the label later once the parent arrives.
*/
@Test
fun replyWithoutParentInCacheReturnsNull() {
val event =
TextNoteEvent(
"",
"",
0,
arrayOf(arrayOf("e", parentEventId, "", "reply")),
"agreed",
"",
)
val cache = StubCache(notesById = emptyMap())
val ctx = ReplyContext.from(event, cache)
assertNull(ctx)
}
/** Address coordinates contain colons; event IDs do not. */
@Test
fun addressCoordDiscriminatorContainsColon() {
val rawAddress = "30023:$parentAuthorPubKey:my-article"
assertTrue(rawAddress.contains(":"))
assertTrue(!parentEventId.contains(":"))
}
private class StubCache(
private val notesById: Map<HexKey, Note>,
private val users: Map<HexKey, User> = emptyMap(),
) : ICacheProvider {
override fun getAnyChannel(note: Note): Channel? = null
override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey]
override fun countUsers(predicate: (String, User) -> Boolean): Int = 0
override fun getNoteIfExists(hexKey: HexKey): Note? = notesById[hexKey]
override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey]
override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("not used by ReplyContext.from")
override fun getEventStream(): ICacheEventStream = error("not used by ReplyContext.from")
override fun hasBeenDeleted(event: Any): Boolean = false
override fun getOrCreateUser(pubkey: HexKey): User? = users[pubkey]
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
}
@@ -25,20 +25,22 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean =
private fun isFeedNote(event: Event?): Boolean =
event is TextNoteEvent ||
event is RepostEvent ||
event is GenericRepostEvent
event.isRenderableRepost()
private fun List<Note>.deduplicateReposts(): List<Note> =
distinctBy { note ->
@@ -191,16 +193,36 @@ class DesktopThreadFilter(
/**
* Profile feed: text notes + reposts by a specific pubkey.
*
* When [repliesOnly] is true, the filter switches to "Replies" mode:
* only the pubkey's reply posts NIP-22 [CommentEvent]s and NIP-10
* [TextNoteEvent]s carrying an explicit `reply`/`root` marker. Plain
* unmarked e-tags don't count: modern clients use those for quotes
* and mentions, and `Note.isNewThread()` (which is what Android's
* conversations feed checks) would let those through as "replies".
*/
class DesktopProfileFeedFilter(
private val pubkey: HexKey,
private val cache: DesktopLocalCache,
private val repliesOnly: Boolean = false,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "profile-$pubkey"
override fun feedKey(): String = if (repliesOnly) "profile-$pubkey-replies" else "profile-$pubkey"
private fun isReply(event: Event): Boolean =
when (event) {
is CommentEvent -> true
is TextNoteEvent -> event.markedReply() != null || event.markedRoot() != null
else -> false
}
private fun isProfileNote(note: Note): Boolean {
val event = note.event ?: return false
return note.author?.pubkeyHex == pubkey && isFeedNote(event)
if (note.author?.pubkeyHex != pubkey) return false
return if (repliesOnly) {
isReply(event)
} else {
isFeedNote(event)
}
}
override fun feed(): List<Note> =
@@ -66,6 +66,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -104,6 +105,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip
import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberNewPostsChipState
import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
@@ -143,12 +145,15 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope
@@ -257,6 +262,7 @@ fun FeedNoteCard(
onFollow != null &&
originalEvent.pubKey != myPubKeyHex &&
originalEvent.pubKey !in followedUsers
val innerReplyContext = rememberReplyContext(originalEvent, localCache)
NoteCard(
note = displayData,
modifier = Modifier.fillMaxWidth(),
@@ -301,6 +307,8 @@ fun FeedNoteCard(
} else {
null
},
replyContext = innerReplyContext,
onNavigateToThread = onNavigateToThread,
)
}
} else {
@@ -326,6 +334,7 @@ fun FeedNoteCard(
onFollow != null &&
event.pubKey != myPubKeyHex &&
event.pubKey !in followedUsers
val replyContext = rememberReplyContext(event, localCache)
NoteCard(
note = displayData,
modifier = Modifier.fillMaxWidth(),
@@ -370,10 +379,68 @@ fun FeedNoteCard(
} else {
null
},
replyContext = replyContext,
onNavigateToThread = onNavigateToThread,
)
}
}
/**
* Detects whether [event] is a reply (NIP-10 or NIP-22), observes the
* parent's metadata flow so the embed and label pop in once it arrives,
* and returns a [ReplyContext] (or null for non-replies).
*
* For addressable parents (`a` coord), the embed is skipped and only the
* "Replying to @X" label renders see ReplyContext.from semantics.
*/
@Composable
private fun rememberReplyContext(
event: Event,
localCache: DesktopLocalCache,
): ReplyContext? {
val threaded = event as? BaseThreadedEvent ?: return null
val replyTargetEventId =
remember(event) {
threaded
.replyingToAddressOrEvent()
?.takeUnless { it.contains(":") }
}
// Observe parent NOTE metadata so recomposition picks up the parent event /
// author when it arrives via relay subscription.
val parentNote =
replyTargetEventId?.let {
remember(it) { localCache.getOrCreateNote(it) }
}
val parentFlow = parentNote?.let { remember(it) { it.flow() } }
val parentMetaState = parentFlow?.metadata?.stateFlow?.collectAsState()
val parentMetaValue = parentMetaState?.value
DisposableEffect(parentNote) {
onDispose { parentNote?.clearFlow() }
}
// Also observe the parent AUTHOR's user metadata (kind 0) flow so the
// "Replying to @X" label upgrades from truncated-hex to display name once
// the author metadata arrives. parentAuthor can be null until the parent
// event lands, so produceState (single unconditional composable call) is
// used to avoid the "conditional composable call" slot-structure trap.
val parentAuthor = parentNote?.author
val parentAuthorMetaValue by produceState<Any?>(initialValue = null, key1 = parentAuthor) {
val author = parentAuthor
if (author == null) {
value = null
} else {
author.metadata().flow.collect { value = it }
}
}
return remember(event, parentMetaValue, parentAuthorMetaValue) {
ReplyContext.from(threaded, localCache)
}
}
@OptIn(FlowPreview::class)
@Composable
fun FeedScreen(
@@ -609,6 +676,15 @@ fun FeedScreen(
.mapNotNull { it.replyTo?.lastOrNull() }
val repostIds = repostOriginals.filter { it.event == null }.map { it.idHex }
// Reply parents — when the visible note is a reply, fetch the immediate parent
// so QuotedNoteEmbed can render it. Skip addressable-coord parents (kind 30023 etc).
val replyParentIds =
notes
.mapNotNull { note ->
val evt = note.event as? BaseThreadedEvent ?: return@mapNotNull null
evt.replyingToAddressOrEvent()?.takeUnless { it.contains(":") }
}.filter { localCache.getNoteIfExists(it)?.event == null }
// Quoted note IDs from content bech32s (nostr:nevent/nostr:note references)
val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event }
val contentQuotedIds =
@@ -623,7 +699,7 @@ fun FeedScreen(
}
}.filter { localCache.getNoteIfExists(it)?.event == null }
(repostIds + contentQuotedIds).distinct()
(repostIds + replyParentIds + contentQuotedIds).distinct()
}
rememberSubscription(allRelayUrls, missingNoteIds, relayManager = relayManager) {
@@ -650,6 +726,20 @@ fun FeedScreen(
.filter { it.event is RepostEvent || it.event is GenericRepostEvent }
.mapNotNull { it.replyTo?.lastOrNull() }
// Reply-parent authors — extract DIRECTLY from each visible reply's tags
// (NIP-22 ReplyAuthorTag for CommentEvent; NIP-10 last p-tag convention for
// TextNote / other threaded events). Doesn't require the parent event itself
// to be in cache, so the metadata fetch races in parallel with the parent-event
// fetch above and lands as soon as either relay returns kind 0 for the author.
val replyParentAuthorHexes =
notes.mapNotNull { note ->
when (val evt = note.event) {
is CommentEvent -> evt.replyAuthor()?.pubKey
is BaseThreadedEvent -> evt.taggedUsers().lastOrNull()?.pubKey
else -> null
}
}
// All notes in cache that are referenced by visible notes
val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event }
val quotedNotes =
@@ -663,11 +753,28 @@ fun FeedScreen(
}
}
// Authors from feed notes + repost originals + quoted notes
(notes.mapNotNull { it.author } + repostOriginals.mapNotNull { it.author } + quotedNotes.mapNotNull { it.author })
.filter { it.profilePicture() == null }
.map { it.pubkeyHex }
.distinct()
// Authors from feed notes + repost originals + quoted notes (User-based —
// filter by missing profile picture). Reply-parent authors are hex-only
// (the parent User may not yet exist in cache) so they're concat'd as hexes
// after the User → hex projection.
val knownAuthorHexes =
(
notes.mapNotNull { it.author } +
repostOriginals.mapNotNull { it.author } +
quotedNotes.mapNotNull { it.author }
).filter { it.profilePicture() == null }
.map { it.pubkeyHex }
val replyParentAuthorMissing =
replyParentAuthorHexes.filter {
localCache
.getOrCreateUser(it)
.metadataOrNull()
?.flow
?.value == null
}
(knownAuthorHexes + replyParentAuthorMissing).distinct()
}
rememberSubscription(allRelayUrls, missingAuthorPubkeys, relayManager = relayManager) {
@@ -204,6 +204,26 @@ fun UserProfileScreen(
} else {
kotlinx.collections.immutable.persistentListOf()
}
// User's replies — separate VM, same cache. Predicate inside the filter.
val repliesViewModel =
remember(pubKeyHex) {
DesktopFeedViewModel(
DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true),
localCache,
)
}
DisposableEffect(repliesViewModel) {
onDispose { repliesViewModel.destroy() }
}
val repliesFeedState by repliesViewModel.feedState.feedContent.collectAsState()
val repliesLoadedNotes =
if (repliesFeedState is FeedState.Loaded) {
val loaded by (repliesFeedState as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
var retryTrigger by remember { mutableStateOf(0) }
// Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel
@@ -831,15 +851,18 @@ fun UserProfileScreen(
Text("Notes", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text("Replies", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) {
Text(
"Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) {
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) {
Text("Gallery", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) {
Tab(selected = selectedTab == 4, onClick = { selectedTab = 4 }) {
Text(
"Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}",
modifier = Modifier.padding(12.dp),
@@ -942,6 +965,96 @@ fun UserProfileScreen(
}
1 -> {
when (repliesFeedState) {
is FeedState.Loading -> {
item(key = "replies-loading") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.material3.CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Loading replies...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
is FeedState.Empty -> {
item(key = "replies-empty") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
is FeedState.FeedError -> {
item(key = "replies-error") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load replies",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
(repliesFeedState as FeedState.FeedError).errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { retryTrigger++ }) {
Text("Retry")
}
}
}
}
}
is FeedState.Loaded -> {
items(repliesLoadedNotes, key = { "reply-${it.idHex}" }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
}
}
2 -> {
if (articleEvents.isEmpty()) {
item(key = "no-articles") {
Box(
@@ -973,7 +1086,7 @@ fun UserProfileScreen(
}
}
2 -> {
3 -> {
item(key = "gallery") {
GalleryTab(
pictureEvents = pictureEvents,
@@ -983,7 +1096,7 @@ fun UserProfileScreen(
}
}
3 -> {
4 -> {
if (highlightEvents.isEmpty()) {
item(key = "no-highlights") {
Box(
@@ -194,9 +194,18 @@ fun DesktopRichTextViewer(
modifier = Modifier.fillMaxWidth(),
)
} else {
// RichTextParser splits paragraphs on ' ' so each segment is one
// space-delimited token; the source space lives BETWEEN segments,
// not within them. spacedBy(4.dp) restores that inter-word gap
// so mixed-content paragraphs (text + mention/hashtag/link) don't
// render as a wall of glued-together words.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (paragraph.isRTL) Arrangement.End else Arrangement.Start,
horizontalArrangement =
Arrangement.spacedBy(
4.dp,
if (paragraph.isRTL) Alignment.End else Alignment.Start,
),
) {
for (word in paragraph.words) {
RenderSegment(word, state, localCache, callbacks)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -43,6 +44,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -55,6 +57,8 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
@@ -103,6 +107,8 @@ fun NoteCard(
onPayInvoice: ((String) -> Unit)? = null,
bottomContent: (@Composable ColumnScope.() -> Unit)? = null,
headerTrailingContent: (@Composable () -> Unit)? = null,
replyContext: ReplyContext? = null,
onNavigateToThread: ((String) -> Unit)? = null,
) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls =
@@ -160,6 +166,55 @@ fun NoteCard(
val cardShape = MaterialTheme.shapes.medium
val cardBody: @Composable ColumnScope.() -> Unit = {
Column(modifier = Modifier.padding(16.dp)) {
// Reply context — embedded parent + "Replying to @X" label.
// Rendered above the standard author/timestamp row.
replyContext?.let { ctx ->
val parentNoteId = ctx.parentNoteId
if (parentNoteId != null) {
// Box owns the click so it consumes the pointer event before the
// outer OutlinedCard's onClick (which would navigate to the reply's
// own thread — current view, looking like "click does nothing").
// Pass onNavigateToThread = null into QuotedNoteEmbed so the inner
// card isn't separately clickable, keeping the click target a
// single explicit surface.
val onEmbedClick =
if (onNavigateToThread != null) {
{ onNavigateToThread.invoke(parentNoteId) }
} else {
null
}
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.then(
if (onEmbedClick != null) {
Modifier.clickable(onClick = onEmbedClick)
} else {
Modifier
},
).border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(8.dp),
),
) {
QuotedNoteEmbed(
noteId = parentNoteId,
localCache = localCache,
onMentionClick = onMentionClick,
onNavigateToThread = null,
)
}
}
ReplyToLabel(
parentAuthorDisplay = ctx.parentAuthorDisplay,
onClick = { onAuthorClick?.invoke(ctx.parentAuthorPubKey) },
modifier = Modifier.padding(vertical = 4.dp),
)
}
Column {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -431,9 +486,27 @@ fun QuotedNoteEmbed(
onDispose { note.clearFlow() }
}
// Observe the author's user metadata (kind 0) so the embed's name + avatar
// update once metadata arrives via relay subscription. produceState avoids
// the conditional-composable trap when note.author is null until the parent
// event lands.
val author = note.author
val authorMetaValue by produceState<Any?>(initialValue = null, key1 = author) {
val a = author
if (a == null) {
value = null
} else {
a.metadata().flow.collect { value = it }
}
}
val event = note.event
if (event != null) {
// Recompute on every recomposition — picks up user metadata changes
// Recompute on every recomposition — picks up note + user metadata changes.
// authorMetaValue is read to mark this recomposition path as author-meta
// dependent so toNoteDisplayData() sees the latest avatar/displayName.
@Suppress("UNUSED_EXPRESSION")
authorMetaValue
val displayData = event.toNoteDisplayData(localCache)
NoteCard(
@@ -0,0 +1,141 @@
/*
* 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.desktop.filters
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DesktopProfileFeedFilterTest {
private val author = "0000000000000000000000000000000000000000000000000000000000000001"
private val parentId = "1111111111111111111111111111111111111111111111111111111111111111"
private val cache = DesktopLocalCache()
private fun user(hex: String): User = User(hex) { addr -> Note(addr.toValue()) }
private fun textNote(
id: String,
pubkey: String,
tags: Array<Array<String>>,
content: String = "hi",
): TextNoteEvent = TextNoteEvent(id, pubkey, 0L, tags, content, "")
private fun replyNote(id: String): Note {
val u = user(author)
val event =
textNote(
id,
author,
arrayOf(arrayOf("e", parentId, "", "reply"), arrayOf("p", author)),
)
val n = Note(id)
n.loadEvent(event, u, listOf(Note(parentId)))
return n
}
private fun rootNote(id: String): Note {
val u = user(author)
val event = textNote(id, author, emptyArray())
val n = Note(id)
n.loadEvent(event, u, emptyList())
return n
}
@Test
fun repliesOnly_includesReply() {
val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true)
val reply = replyNote("aa")
val result = filter.applyFilter(setOf(reply))
assertEquals(setOf(reply), result)
}
@Test
fun repliesOnly_excludesRoot() {
val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true)
val root = rootNote("aa")
val result = filter.applyFilter(setOf(root))
assertTrue("Root post must NOT be in Replies tab", result.isEmpty())
}
@Test
fun repliesOnly_excludesOtherAuthor() {
val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true)
val otherAuthor = "0000000000000000000000000000000000000000000000000000000000000002"
val u = user(otherAuthor)
val event =
textNote(
"bb",
otherAuthor,
arrayOf(arrayOf("e", parentId, "", "reply"), arrayOf("p", author)),
)
val n = Note("bb")
n.loadEvent(event, u, listOf(Note(parentId)))
val result = filter.applyFilter(setOf(n))
assertTrue("Reply by another author must be excluded", result.isEmpty())
}
@Test
fun notesMode_includesBoth() {
val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = false)
val root = rootNote("aa")
val reply = replyNote("bb")
val result = filter.applyFilter(setOf(root, reply))
assertEquals(setOf(root, reply), result)
}
/**
* Regression: an unmarked e-tag is the legacy positional NIP-10 form,
* but modern clients use it for QUOTES/MENTIONS, not replies. The Replies
* tab must NOT include such posts. The looser `!isNewThread()` check
* (which Android's conversations filter uses) would falsely include them.
*/
@Test
fun repliesOnly_excludesQuoteWithUnmarkedETag() {
val filter = DesktopProfileFeedFilter(author, cache, repliesOnly = true)
val u = user(author)
val event =
textNote(
"cc",
author,
// Unmarked e-tag — typical of an inline `nostr:note1...` quote.
arrayOf(arrayOf("e", parentId)),
)
val n = Note("cc")
// Cache would populate replyTo from tagsWithoutCitations(), which
// includes unmarked tags. Mirror that here.
n.loadEvent(event, u, listOf(Note(parentId)))
val result = filter.applyFilter(setOf(n))
assertTrue("Quote (unmarked e-tag) must NOT appear in Replies tab", result.isEmpty())
}
@Test
fun feedKeysDifferByMode() {
val notes = DesktopProfileFeedFilter(author, cache, repliesOnly = false)
val replies = DesktopProfileFeedFilter(author, cache, repliesOnly = true)
assertFalse(notes.feedKey() == replies.feedKey())
}
}
@@ -0,0 +1,172 @@
---
title: Desktop Profile — Replies Tab
type: feat
status: active
date: 2026-06-05
---
# Desktop Profile — Replies Tab
## Enhancement Summary (deepen-plan, 2026-06-05)
Source-verified facts (carried into the final design):
- **`isNewThread()` semantics** (`commons/.../model/Note.kt:775-783`): returns
true when `(event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.size == 0)`
AND `event !is ChannelMessageEvent` AND `event !is LiveActivitiesChatMessageEvent`.
Important edge case: `!isNewThread()` would include channel/live messages,
so the replies-filter predicate must also constrain by event type.
- **Replies predicate (final):** `author == pubkey && event is TextNoteEvent && !isNewThread()`.
Using `event is TextNoteEvent` rather than `isFeedNote()` excludes reposts
AND channel messages in one shot. NIP-22 kind 1111 deferred.
- **Notes predicate (final):** add `note.isNewThread()` to existing
`author == pubkey && isFeedNote(event)`. Safe because `isFeedNote()`
restricts to kind 1/6/16 — none of which are channel/live messages, so
the `isNewThread()` channel-message exclusion clause never fires here.
- **`DesktopProfileFeedFilter` shape** (`DesktopFeedFilters.kt:140-163`):
`AdditiveFeedFilter<Note>` with `feed()`, `applyFilter(newItems)`,
`sort(items)`, `limit()` overrides. The replies filter must override all
four to match the framework contract — covered in Phase 1.
- **`DesktopFeedViewModel` lifecycle** (`UserProfileScreen.kt:153-163`):
remembered keyed on `pubKeyHex`; disposed via `DisposableEffect`. The
replies VM follows identical pattern.
- **Tab structure** (`UserProfileScreen.kt:714-735`): `PrimaryTabRow` with
`selectedTab` state at line 198. Tab body branches at line 738 via
`when (selectedTab)`. Inserting at index 1 requires shifting Reads (1→2),
Gallery (2→3), Highlights (3→4) — touched in two places (tabs + body).
- **Relay subscription** (`UserProfileScreen.kt:174-195`): already pulls
kind 1/6/16 via `FilterBuilders.textNotesFromAuthors`. Covers NIP-10
replies. No subscription change needed.
## Overview
Add a "Replies" tab to the desktop profile screen alongside the existing
Notes tab, so the reply-context rendering from the prior PR can be tested
on profile feeds. Scope is intentionally narrow: **purely additive**, the
existing Notes tab is unchanged.
## Problem Statement
Reply-context rendering (just shipped) is wired into every screen that
funnels through `FeedNoteCard` — profile included. But the only profile
feed currently is "Notes" which mixes everything, so a user testing the
feature has to scroll to find an organic reply. A dedicated Replies tab
is the straightforward QA affordance.
## Proposed Solution
1. **Extend `DesktopProfileFeedFilter`** (existing) with a `repliesOnly: Boolean = false`
constructor parameter. When `false` (default): keeps existing predicate
exactly — Notes tab behavior unchanged. When `true`: predicate becomes
`author == pubkey && event is TextNoteEvent && !note.isNewThread()`.
2. **Wire a second `DesktopFeedViewModel`** in `UserProfileScreen.kt` with
`repliesOnly = true`.
3. **Insert "Replies" tab at index 1** between Notes and Reads; shift Reads
to 2, Gallery to 3, Highlights to 4.
4. **Render** the replies feed using the same `FeedNoteCard` pipeline — reply
context engages automatically.
No relay-subscription change: existing `textNotesFromAuthors` already pulls
kinds 1/6/16 from the user, which covers all NIP-10 replies. NIP-22 kind
1111 deferred — most replies today are kind 1.
**Out of scope (deliberately):**
- Splitting Notes/Replies in the existing Notes tab — separate concern,
potential UX regression for users who like the mixed feed, not
load-bearing for this QA goal.
- Count badge on the Replies tab ("Replies (N)") — premature; Reads/Highlights
count their statically-loaded events, replies are dynamic via FeedViewModel.
- Behavior changes to global / following / bookmark feeds.
## Survey Matrix
| Component | Status | Location | Action |
|---|---|---|---|
| `Note.isNewThread()` | ✅ Reuse | `commons/.../model/Note.kt:775` | Canonical reply-vs-root check |
| `DesktopProfileFeedFilter` | 📦 Extend | `desktopApp/.../feeds/DesktopFeedFilters.kt:140-163` | Add `repliesOnly: Boolean = false` ctor param + branched predicate |
| `DesktopFeedViewModel` | ✅ Reuse | existing | Instantiate a second VM with `repliesOnly = true` |
| Profile relay subscription | ✅ Reuse | `UserProfileScreen.kt:174-195` | Already pulls kind 1; covers NIP-10 replies |
| `PrimaryTabRow` + tab indices | 📦 Extend | `UserProfileScreen.kt:714-735` | Insert "Replies" tab; shift Reads/Gallery/Highlights indices |
| Tab body render | 📦 Extend | `UserProfileScreen.kt:738+` | Add `when (selectedTab) { 1 -> ... }` branch mirroring index 0 |
| Android equivalent | 📖 Reference | `amethyst/.../profile/conversations/dal/UserProfileConversationsFeedFilter.kt` | Pattern only (`acceptableEvent` with `!isNewThread()`) |
## Phases
### Phase 1 — Filter + Profile screen wiring
Files:
- `desktopApp/.../feeds/DesktopFeedFilters.kt`:
- Add `repliesOnly: Boolean = false` constructor parameter to
`DesktopProfileFeedFilter`.
- Update `feedKey()` to include the mode so the two filter instances
don't collide cache-wise: `"profile-$pubkey${if (repliesOnly) "-replies" else ""}"`.
- Branch the predicate inside `isProfileNote`:
- `repliesOnly = false` (default): existing predicate, unchanged.
- `repliesOnly = true`: `author == pubkey && event is TextNoteEvent && !note.isNewThread()`.
- The `event is TextNoteEvent` check (rather than `isFeedNote()`)
excludes reposts AND `ChannelMessageEvent` / `LiveActivitiesChatMessageEvent`
(which `!isNewThread()` would otherwise let through).
- `desktopApp/.../ui/UserProfileScreen.kt`:
- Add a `repliesViewModel = remember(pubKeyHex) { DesktopFeedViewModel(DesktopProfileFeedFilter(pubKeyHex, localCache, repliesOnly = true), localCache) }`
next to the existing `profileViewModel` (line 154). Add a matching
`DisposableEffect` to destroy it.
- Collect its feed state into `repliesFeedState` + derive
`repliesLoadedNotes` mirroring the existing Notes setup (lines 164-171).
- Insert a "Replies" tab at index 1 in the `PrimaryTabRow` (lines 714-735).
Shift Reads → 2, Gallery → 3, Highlights → 4. (Updates needed in tab
declarations AND in the `when (selectedTab)` body branches.)
- Add a `1 -> { ... }` body branch that mirrors the Notes branch
(`Loading` / `Empty` / `FeedError` / `Loaded`) but uses `repliesFeedState`
and `repliesLoadedNotes`. Empty-state copy: "No replies yet".
Verify (single command — Phases 1 and 2 must build together since they're
cross-file):
- `./gradlew :desktopApp:compileKotlin`
- `./gradlew spotlessApply`
Manual sanity:
- Open a profile with mixed posts. Notes tab = unchanged (still shows
everything as before). Replies tab = only the user's reply posts, each
rendered with the embedded parent card + "Replying to @X" label.
### Phase 2 — (none)
Folded into Phase 1 per the review pass — separate phases for a single
cross-file edit + format was ceremonial.
## Acceptance Criteria
- [ ] Profile screen shows a "Replies" tab between Notes and Reads.
- [ ] Notes tab behavior is UNCHANGED — still shows whatever it did before.
- [ ] Replies tab shows ONLY the user's reply posts (kind 1 with parent tags).
- [ ] Each reply in the Replies tab renders with the embedded parent + "Replying to @X" label (validates the prior PR end-to-end on profiles).
- [ ] Tab switching is responsive (no scroll-position weirdness, no flash).
- [ ] Cross-module compiles green; spotless clean.
## Testing
Manual UI:
1. `./gradlew :desktopApp:run` from the worktree.
2. Open the profile of an account that posts a mix of root notes and replies (your own account works).
3. Notes tab → only top-level posts visible.
4. Replies tab → only replies visible, each with parent embed + label.
5. Confirm reposts appear in Notes, not Replies.
6. Confirm switching tabs is smooth.
## Dependencies & Risks
- **NIP-22 (kind 1111) replies skipped in v1.** Most replies are kind 1.
Adding kind 1111 needs `isFeedNote()` extension or new helper, plus possibly
extending the relay subscription. Defer.
- **Existing Notes tab will lose replies** — intended behavior change.
Anyone relying on seeing replies in the Notes feed will find them in the new
Replies tab. Document in PR body.
## Unanswered Questions
- Manual scan needed during testing: is the second `DesktopFeedViewModel`'s
cache-scan overhead noticeable on profile open? Existing pattern already
does one full scan per profile; this doubles it. If it shows up in latency,
fallback is derive-from-loaded-list at render time.
- NIP-22 kind 1111 inclusion — deferred. Add in a follow-up if QA confirms
enough kind 1111 traffic on the relays used.
@@ -0,0 +1,814 @@
---
title: Desktop Feed — Reply Context Rendering
type: fix
status: active
date: 2026-06-05
origin: docs/brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md
---
# Desktop Feed — Reply Context Rendering
## Enhancement Summary
**Deepened on:** 2026-06-05
Source-verified facts (carried into the final design):
- **NIP-10 / NIP-22 unified**`BaseThreadedEvent.replyingToAddressOrEvent()` is overridden by `CommentEvent` (kind 1111). One polymorphic call covers both, no caller-side branching.
- **Return type**`replyingToAddressOrEvent(): String?` is a flat string. Disambiguate event-id vs address by `raw.contains(":")`.
- **`QuotedNoteEmbed` is reusable as-is** — signature `(noteId: String, localCache, onMentionClick?, onNavigateToThread?)`. Already handles cache lookup, loading-state, and async parent arrival.
- **Addressable parents** — v1 renders label-only. `QuotedNoteEmbed` doesn't accept `Address`; embed is follow-up.
- **Existing styling**`MaterialTheme.colorScheme.outlineVariant` (1.dp border, used in `ThreadScreen.kt:283`). No new theme keys.
- **FlowRow** — available in `commons/commonMain/` (live precedent: `commons/.../nip23LongContent/ui/editor/MetadataPanel.kt:26`).
- **Strings location**`commons/src/commonMain/composeResources/values/strings.xml`. Package `com.vitorpamplona.amethyst.commons.resources`. Domain-grouped, NOT alphabetized — add new `<!-- Notes & Replies -->` section.
- **Reposted-reply** — FeedScreen.kt:131 extracts inner note via `note.replyTo?.lastOrNull()` then calls `NoteCard` on it. Phase 3 wiring passes a fresh `replyContext` for the inner note — reply context engages naturally on the reposted reply.
- **Cache parent observation pattern**`note.flow().metadata.stateFlow.collectAsState()` drives recomposition on parent arrival.
## Review Findings Applied (2026-06-05)
After deepen-plan, three parallel reviewers (architecture-strategist,
code-simplicity-reviewer, pattern-recognition-specialist) flagged structural
issues. Applied revisions:
1. **`ReplyContext` moves to `commons/commonMain/`** (architect's blocker).
Pure protocol→display data with no platform deps; if left in desktopApp,
Android re-implements when it extracts.
2. **Drop the `withReplyContext: Boolean` flag** (simplicity). The flag
existed only to break recursion. Instead: keep `NoteDisplayData` strictly
display-only and pass `replyContext: ReplyContext?` as a *separate*
parameter to `NoteCard`. `QuotedNoteEmbed` calls `NoteCard` without a
`replyContext` (default null) — recursion impossible by construction. No
`toNoteDisplayData` callsite audit, no behavior surprise for bookmarks /
search / quote-mentions.
3. **Drop `ReplyRenderType` extraction** (simplicity). Desktop only uses
FULL; Android's LINE/NONE branches stay in `amethyst/`. Enum stays where
it is.
4. **Simplify `parentAuthorHint()`** — drop the brittle last-`p`-tag
heuristic. Use only:
- `CommentEvent.replyAuthor()` for NIP-22, OR
- Read `parent.pubKey` from cache once parent loads.
If neither resolves: omit the label (don't show "Replying to @unknown").
Eventual consistency wins over speculative guesses.
5. **`strings.xml` section header** — add new `<!-- Notes & Replies -->`
group; the file is domain-grouped, not alphabetized.
6. **Skip the commons `ReplyToLabelTest` smoke test** — testing a
two-`Text` composable is theater. Real coverage is the desktop converter
test.
**Gentle deviation from brainstorm:** the brainstorm option chosen was
phrased "add to NoteDisplayData". On sharper analysis, attaching the field
forces a depth-cap mechanism (the flag). Decoupling — pass `ReplyContext`
alongside `NoteDisplayData` rather than embedded inside it — preserves the
brainstorm's core intent ("FeedScreen pre-computes once per item, NoteCard
is a pure render fn") while eliminating the flag. Flagged for explicit
user awareness.
## Overview
Desktop home feed currently renders reply notes as standalone top-level posts —
no indication they're replies, no parent context. Users lose conversational
thread when scrolling the feed. Fix: detect NIP-10 / NIP-22 replies during
event→display-data conversion, then render an embedded parent card plus a
"Replying to @displayName" label above the reply body — matching Android's
`ReplyRenderType.FULL` mode (but capped at 1 quote level for the deck-column
layout).
Per brainstorm decisions (see brainstorm:
`docs/brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md`):
- Render mode: **FULL** (label + embedded parent card)
- Extraction: move shared composables into `commons/commonMain/` now
- Scope: NIP-10 (kind 1) **and** NIP-22 (kind 1111)
- Quote depth: **1 level** (no recursive grandparent)
- Data flow: pre-compute `ReplyContext` in `Event.toNoteDisplayData()`
## Problem Statement
`desktopApp/.../FeedScreen.kt` currently special-cases reposts only (lines
128197). Replies fall through and are rendered by `NoteCard` (NoteCard.kt:95+)
as if they were thread-root posts. Three concrete user impacts:
1. **Lost thread context.** Reply text often only makes sense relative to the
parent ("yes!", "no this is wrong because…"). Without the parent the
feed reads as decontextualized noise.
2. **Lost author cue.** No indication who is being replied to — feed user
can't tell from a glance whether they care.
3. **Parity gap with Android.** Same account, same relays, same notes — the
Android client shows the conversation structure; desktop doesn't.
## Proposed Solution
Three discrete layers of work:
1. **Detection** — reuse `BaseThreadedEvent.replyingToAddressOrEvent()` from
`quartz/` (already KMP-common, already covers both NIP-10 and NIP-22).
No new code needed in `quartz/`.
2. **Shared UI** — extract `ReplyRenderType` enum and `ReplyToLabel`
composable from `amethyst/` to `commons/commonMain/`. Refactor away
Android-only deps (`R.string``Res.string`, `nav.nav(routeFor(...))`
click callback).
3. **Desktop wiring** — extend `NoteDisplayData` with an optional
`replyContext: ReplyContext?` field, populate it inside
`Event.toNoteDisplayData(cache)`, and render it from `NoteCard` using
the shared `ReplyToLabel` + the existing desktop `QuotedNoteEmbed()`
(NoteCard.kt:488535) for the parent card.
## Survey Matrix (per CLAUDE.md convention)
| Component | Status | Location | Action |
|---|---|---|---|
| `replyingToAddressOrEvent()` | ✅ Reuse | `quartz/.../nip10Notes/BaseThreadedEvent.kt` | Call as-is, no changes |
| `ReplyRenderType` enum | ⚠️ Avoid | `amethyst/.../ui/note/types/Text.kt:59-63` | Leave in Android. Desktop only uses FULL; extracting is YAGNI |
| `ReplyToLabel` composable | 📦 Extract | `amethyst/.../ui/note/ReplyInformation.kt:121-142` | Refactor + move to commons; Android switches to shared version |
| `ReplyInfoMention` helper | ⚠️ Avoid | `amethyst/.../ui/note/ReplyInformation.kt:145-163` | Leave in Android. v1 ReplyToLabel takes plain `String`; emoji-in-name regression flagged |
| `ReplyNoteComposition` | ⚠️ Avoid | `amethyst/.../ui/note/NoteCompose.kt:1491-1508` | Hard-coupled to `NoteCompose` (1100+ LOC). Desktop renders parent via its own `QuotedNoteEmbed`. |
| `ReplyContext` data class | 🆕 New | `commons/commonMain/.../ui/note/ReplyContext.kt` | Pure protocol→display struct. Lives in commons so Android can adopt later. |
| Desktop `QuotedNoteEmbed` | ✅ Reuse | `desktopApp/.../ui/note/NoteCard.kt:488-535` | Use unchanged. Recursion impossible because we pass `replyContext` as a separate param to `NoteCard`, not via `NoteDisplayData`. |
| Desktop `NoteCard` | 📦 Extend | `desktopApp/.../ui/note/NoteCard.kt:81-88, 95+` | Add optional `replyContext: ReplyContext?` param, branch render |
| Reply detection / `ReplyContext` build | 🆕 New | `commons/commonMain/.../ui/note/ReplyContext.kt` (companion `from(event, cache)`) | Pure function: `BaseThreadedEvent` + `ICacheProvider``ReplyContext?` |
| `Event.toNoteDisplayData()` | ✅ Reuse | `desktopApp/.../ui/EventExtensions.kt:32-53` | UNCHANGED. Reply detection happens separately in FeedScreen's item render block. |
| `DesktopLocalCache.getNoteIfExists` | ✅ Reuse | `desktopApp/.../cache/DesktopLocalCache.kt:564-571` | Call from converter for parent lookup |
| `User.toBestDisplayName()` | ✅ Reuse | `commons/.../model/User.kt:90` | Author label for "Replying to @X" |
| FeedScreen repost pattern | ✅ Mirror | `desktopApp/.../ui/FeedScreen.kt:128-197` | Apply same flow-observation pattern for parent recompose |
| `Res.string.replying_to` | 🆕 New | `commons/src/commonMain/composeResources/values/strings.xml` | Add string key |
| `Reply.kt` icon | ✅ Reuse | `commons/.../icons/Reply.kt` | Already extracted |
| Reply embed visual border | 🆕 New | `desktopApp/.../ui/note/NoteCard.kt` | Add small subtle-border style; Android has `replyModifier` but desktop has none |
**Legend:** ✅ Reuse · 📦 Extract / Extend · 🆕 New · ⚠️ Avoid (duplicate / blocked)
## Technical Approach
### Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ desktopApp/ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ FeedScreen.kt — LazyColumn item block │ │
│ │ observe note + parent flowSet.metadata │ │
│ │ call Event.toNoteDisplayData(cache) → NoteDisplayData│ │
│ │ └─ with replyContext: ReplyContext? populated │ │
│ │ render NoteCard(noteDisplayData) │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ NoteCard.kt — render path branch │ │
│ │ if (replyContext != null): │ │
│ │ Column { │ │
│ │ QuotedNoteEmbed(replyContext.parent) │ │
│ │ ReplyToLabel(parentAuthor, onUserClick) │ │
│ │ <existing body render> │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ depends on (shared)
┌─────────────────────────────────────────────────────────────┐
│ commons/commonMain/ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ui/note/ReplyToLabel.kt (NEW) │ │
│ │ @Composable fun ReplyToLabel( │ │
│ │ parentAuthor: User, │ │
│ │ onUserClick: (User) -> Unit, │ │
│ │ ) │ │
│ │ ui/note/ReplyRenderType.kt (NEW) │ │
│ │ resources/strings.xml — adds `replying_to` │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ used by
┌─────────────────────────────────────────────────────────────┐
│ amethyst/ (Android) │
│ ReplyInformation.kt::ReplyToLabel — DELETED │
│ Callers updated: pass `onUserClick = { u → nav.nav(routeFor(u)) }`
└─────────────────────────────────────────────────────────────┘
```
### Data Shape: `ReplyContext`
Lives in `commons/commonMain/.../ui/note/ReplyContext.kt`:
```kotlin
package com.vitorpamplona.amethyst.commons.ui.note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
data class ReplyContext(
/** Hex event id of the parent. Null when the reply targets only an addressable (`a` coord). */
val parentNoteId: String?,
/** Hex pubkey of the parent's author. Used by the click handler. */
val parentAuthorPubKey: String,
/** Resolved display name (or truncated hex fallback) for the "Replying to @X" label. */
val parentAuthorDisplay: String,
) {
companion object {
/**
* Pure detection. Returns null if [event] is not a reply, or if we
* can't determine the parent author yet (caller should retry once
* the parent event arrives in cache).
*/
fun from(event: BaseThreadedEvent, cache: ICacheProvider?): ReplyContext? {
val raw = event.replyingToAddressOrEvent() ?: return null
val isAddressable = raw.contains(":")
val parentNoteId = if (isAddressable) null else raw
val parentAuthorPubKey = when (event) {
is CommentEvent -> event.replyAuthor()
else -> null
} ?: parentNoteId?.let { cache?.getNoteIfExists(it)?.author?.pubkeyHex }
?: return null
val parentAuthorDisplay = cache?.getUserIfExists(parentAuthorPubKey)
?.toBestDisplayName()
?: parentAuthorPubKey.take(8) + "…"
return ReplyContext(parentNoteId, parentAuthorPubKey, parentAuthorDisplay)
}
}
}
```
**Three fields.** Dropped from earlier draft:
- `isAddressableParent: Boolean` — redundant; just check `parentNoteId == null`.
- Andriod's last-`p`-tag fallback path — fragile and only saved one render
frame. Now: if we can't resolve the author (parent not cached, not a
CommentEvent), `from()` returns null and the reply renders as a regular
post. When the parent event arrives via subscription, recomposition
re-evaluates `from()` and the label appears.
**ReplyContext is computed at the FeedScreen item-render level**, not at
event→display-data conversion time. It's passed as a *separate parameter*
to `NoteCard`, not embedded in `NoteDisplayData`. This is the structural
fix from the architecture/simplicity review: `NoteDisplayData` stays a
pure display struct; `NoteCard` doesn't need a flag to suppress embedded
recursion (the recursive `QuotedNoteEmbed → NoteCard` chain never receives
a `replyContext` parameter, so it can never render one). Recursion
impossible by construction.
### `NoteDisplayData` — UNCHANGED
After the review pass, `NoteDisplayData` is **not** modified. It stays a
strictly display-only snapshot. Reply context flows alongside it through
the `NoteCard` parameter list. This preserves single-responsibility and
removes the need to audit `toNoteDisplayData`'s 9 callsites.
### Reply Detection — `ReplyContext.from(event, cache)`
All detection logic lives in the `ReplyContext.from()` companion function
(spec'd above). `Event.toNoteDisplayData` is **not** modified. The
FeedScreen item-render block calls `ReplyContext.from()` separately when
preparing each feed item.
**NIP-10 / NIP-22 unification confirmed.**
`BaseThreadedEvent.replyingToAddressOrEvent()` at
`quartz/.../BaseThreadedEvent.kt:78-84` is **overridden** by
`CommentEvent.replyingToAddressOrEvent()` at
`quartz/.../nip22Comments/CommentEvent.kt:224` — polymorphic dispatch
makes our caller blind to which NIP it's handling. One code path.
**Return type:** `replyingToAddressOrEvent(): String?` returns a flat
string with no discriminant. Disambiguation: `raw.contains(":")`
addresses use `kind:pubkey:d-tag` format; event IDs are pure hex.
### FeedScreen Integration
Mirror the repost pattern at FeedScreen.kt:128152, which uses
`originalNote.flow()``NoteFlowSet``.metadata.stateFlow.collectAsState()`.
The `Note.flow()` accessor lives at `commons/.../model/Note.kt:913-916` and
returns a `NoteFlowSet` with `metadata`, `reactions`, `replies`, `zaps`
all `StateFlow<T>`.
```kotlin
// For each LazyColumn item, before rendering NoteCard:
val displayData = remember(event, metadataState) {
event.toNoteDisplayData(localCache)
}
// Reply-context computation — observe parent metadata so the label/embed
// pop in once the parent arrives.
val replyTargetEventId = remember(event) {
(event as? BaseThreadedEvent)
?.replyingToAddressOrEvent()
?.takeUnless { it.contains(":") } // skip addressable; label-only path
}
val parentNote = replyTargetEventId?.let {
remember(it) { localCache.getOrCreateNote(it) }
}
val parentMetaState = parentNote?.let {
remember(it) { it.flow() }.metadata.stateFlow.collectAsState()
}
val replyContext = remember(event, parentMetaState?.value) {
(event as? BaseThreadedEvent)?.let { ReplyContext.from(it, localCache) }
}
NoteCard(
displayData = displayData,
replyContext = replyContext, // NEW: sibling param
localCache = localCache,
onNavigateToThread = onNavigateToThread,
onNavigateToProfile = onNavigateToProfile,
)
```
**Why this works.**
- `getOrCreateNote` always returns a `Note` — placeholder with `event = null`
if not yet fetched. The `NoteFlowSet.metadata` still emits when the event
later arrives (verified — same pattern reposts use today).
- `remember(event, parentMetaState?.value)` invalidates the `replyContext`
computation when the parent's metadata changes. Parent embed pops in via
recomposition without scroll-position jump.
- The `?.takeUnless { it.contains(":") }` guards against trying to fetch an
addressable note by event id; addressable parents render label-only.
- `ReplyContext.from()` returns null when author can't be resolved — reply
renders as a regular post until parent arrives (eventual consistency).
**Subscription batching.** Existing feed subscription (FeedScreen.kt:419429)
already batches missing event IDs. Extend the missing-set computation:
```kotlin
val missingParentIds = displayItems.mapNotNull { item ->
val target = (item.event as? BaseThreadedEvent)
?.replyingToAddressOrEvent()
?.takeUnless { it.contains(":") }
target?.takeIf { localCache.getNoteIfExists(it)?.event == null }
}
```
Pipe these into the existing batch — no new subscription path.
### NoteCard Render Path
`QuotedNoteEmbed` signature (NoteCard.kt:488-493) — **UNCHANGED**:
```kotlin
@Composable
fun QuotedNoteEmbed(
noteId: String,
localCache: DesktopLocalCache?,
onMentionClick: ((String) -> Unit)? = null,
onNavigateToThread: ((String) -> Unit)? = null,
)
```
Recursion is impossible because `replyContext` is a `NoteCard` parameter,
not a field of `NoteDisplayData`. When `QuotedNoteEmbed` internally calls
`NoteCard(...)` for the parent (line 512), it doesn't pass `replyContext`
— the default null applies and the parent renders without further embed.
NoteCard render path:
```kotlin
@Composable
fun NoteCard(
displayData: NoteDisplayData,
localCache: DesktopLocalCache?,
onNavigateToThread: (String) -> Unit,
onNavigateToProfile: (String) -> Unit,
replyContext: ReplyContext? = null, // NEW — default null suppresses embed recursion
...
) {
Column {
replyContext?.let { ctx ->
if (ctx.parentNoteId != null) {
Box(
modifier = Modifier
.padding(top = 4.dp)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(8.dp),
),
) {
QuotedNoteEmbed(
noteId = ctx.parentNoteId,
localCache = localCache,
onNavigateToThread = onNavigateToThread,
)
}
}
ReplyToLabel(
parentAuthorDisplay = ctx.parentAuthorDisplay,
onClick = { onNavigateToProfile(ctx.parentAuthorPubKey) },
modifier = Modifier.padding(vertical = 2.dp),
)
}
// existing body / actions render — unchanged
}
}
```
**Visual styling.** `MaterialTheme.colorScheme.outlineVariant` (1.dp border)
is already used in `ThreadScreen.kt:283`. Matches Android's `replyModifier`
intent without inventing new theme keys. Rounded 8.dp corners match the
existing card chrome of the `Loading quoted note...` placeholder.
### Shared Composable: `ReplyToLabel` (in commons)
```kotlin
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt
@Composable
fun ReplyToLabel(
parentAuthorDisplay: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(modifier = modifier) {
Text(
text = stringResource(Res.string.replying_to),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
)
Text(
text = "@$parentAuthorDisplay",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
modifier = Modifier.clickable(onClick = onClick),
)
}
}
```
Key extraction refactor decisions:
- **String:** `R.string.replying_to` → new `Res.string.replying_to` in
`commons/.../composeResources/values/strings.xml`. Localized strings already
in Android `res/values-*/strings.xml` will move to commons in a follow-up
(out of scope for this fix — English-only commons string acceptable for v1
per existing commons string practice).
- **Navigation:** drop the `INav` + `routeFor` coupling; expose `onClick: () -> Unit`
callback. Android caller passes
`{ nav.nav(routeFor(parentAuthorUser)) }`; desktop passes
`{ onNavigateToProfile(pubKey) }`.
- **User lookup:** caller resolves the display name and passes it as
`String` rather than passing a `User` and resolving inside. Keeps the
composable pure of cache dependencies. This deliberately diverges from the
original Android `ReplyToLabel` signature which took a `Note` — the
refactored version is cleaner and equally functional.
- **`ReplyInfoMention` helper (lines 145163):** the simpler "render
display name + emoji" logic is inlined into the new `ReplyToLabel` for
v1; if desktop later needs the emoji-aware variant we extract that too.
Android currently uses `ReplyInfoMention` for emoji rendering inside the
label — for v1, the simple `@displayName` text is acceptable; Android may
regress on inline emoji in display names until follow-up.
**Trade-off flagged:** the Android user briefly loses inline custom-emoji
rendering in the "Replying to @X" line. Acceptable for this fix; raise as
follow-up.
### Shared: `ReplyRenderType` enum
```kotlin
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyRenderType.kt
enum class ReplyRenderType { FULL, LINE, NONE }
```
Trivial extraction. Android `Text.kt` imports from commons instead of
declaring locally.
## Implementation Phases
Each phase is independently buildable and testable. Don't move on until the
previous phase compiles + (where applicable) `./gradlew test` passes.
### Phase 1 — Commons Additions
**Goal:** add shared `ReplyContext` data class and `ReplyToLabel` composable
to commons + add the `replying_to` string. No behavior change yet.
Files (exact paths confirmed against repo):
- `commons/src/commonMain/composeResources/values/strings.xml` — add new
section header and key. `strings.xml` is **domain-grouped** (see existing
`<!-- Login & Auth -->`, `<!-- Common Actions -->`, `<!-- Errors -->`,
`<!-- Loading & Empty States -->`, `<!-- Accessibility -->` sections).
Append:
```xml
<!-- Notes & Replies -->
<string name="replying_to">replying to </string>
```
Trailing space matches Android original. Package for generated `Res` class
is `com.vitorpamplona.amethyst.commons.resources` (confirmed at
`commons/build.gradle.kts:194`).
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContext.kt` — new file with the data class + companion `from()`.
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyToLabel.kt` — new composable. Uses `androidx.compose.foundation.layout.FlowRow` (live precedent at `commons/.../nip23LongContent/ui/editor/MetadataPanel.kt:26`) and `org.jetbrains.compose.resources.stringResource`.
**Not extracted:** `ReplyRenderType` enum stays in Android. Desktop only
uses one mode (FULL); extracting just to keep Android importing from
commons is YAGNI.
Verify:
- `./gradlew :commons:compileKotlinJvm` succeeds.
- `./gradlew :commons:compileKotlinAndroid` succeeds.
### Phase 2 — Desktop NoteCard Parameter
**Goal:** add a `replyContext: ReplyContext? = null` parameter to
`NoteCard`. Branch render in the body. `NoteDisplayData` and
`toNoteDisplayData` are NOT touched.
Files:
- `desktopApp/.../ui/note/NoteCard.kt`
- Import `ReplyContext` and `ReplyToLabel` from commons.
- Add `replyContext: ReplyContext? = null` and (if not already present)
`localCache: DesktopLocalCache?` parameters to `NoteCard`.
- Insert the render branch above the body (see "NoteCard Render Path"
section above).
**Recursion impossibility (by construction).** `QuotedNoteEmbed` internally
calls `NoteCard(...)` without passing `replyContext` — the default `null`
applies, no embed. No flag, no callsite audit, no behavior change to any
non-feed surface.
**Existing call sites — do they need updates?** No. They all pass the
default `null` for `replyContext`. Bookmarks / search / quote-mentions
continue rendering exactly as today. Reply context engages only in feed
contexts where Phase 3 explicitly passes a non-null value.
Verify:
- `./gradlew :desktopApp:compileKotlin` succeeds.
- Existing app still renders feed identically (Phase 3 wires the actual
detection + observation).
### Phase 3 — Desktop FeedScreen Wiring
**Goal:** FeedScreen computes `ReplyContext` per item, observes parent
metadata for async arrival, passes context to `NoteCard`. Replies render
with embedded parent + label.
Files:
- `desktopApp/.../ui/FeedScreen.kt`
- In each LazyColumn item (around lines 230-260, the normal note render
path; mirror the repost pattern at lines 128-152 for the flow
observation):
- Compute `replyTargetEventId = event.replyingToAddressOrEvent()?.takeUnless { it.contains(":") }`
- If non-null, `getOrCreateNote(it)` and observe `note.flow().metadata.stateFlow.collectAsState()`
- Compute `ReplyContext.from(event as BaseThreadedEvent, localCache)` in a `remember(event, parentMetaState?.value)` block
- Pass the resulting `replyContext` to `NoteCard`
- Extend the missing-event-IDs batch (lines 419429) with reply parent IDs
so relay subscriptions fetch them along with everything else.
Manual check after this phase: see Testing section.
### Phase 4 — Android Switchover
**Goal:** Android's `ReplyToLabel` definition deletes; callers use the
shared commons version.
Files:
- `amethyst/.../ui/note/ReplyInformation.kt` — delete `ReplyToLabel`
function (lines 121142). Keep `ReplyInfoMention` (lines 145163) — it's
still used elsewhere if any caller remains (grep first).
- `amethyst/.../ui/note/types/Text.kt`
- In `RenderTextEvent`'s LINE branch (lines 117-121), replace the local
`ReplyToLabel` call with the imported commons version. Resolve the
parent author's display name at the call site (currently the Android
version did it internally):
```kotlin
val parentAuthor = remember(replyingDirectlyTo) {
replyingDirectlyTo.author?.toBestDisplayName().orEmpty()
}
ReplyToLabel(
parentAuthorDisplay = parentAuthor,
onClick = { replyingDirectlyTo.author?.let { nav.nav(routeFor(it)) } },
)
```
- `amethyst/src/main/res/values/strings.xml` — remove `replying_to`
(now lives in commons).
- `amethyst/src/main/res/values-*/strings.xml` — leave translations until
follow-up that migrates to commons multi-locale resources.
**Not changed in Phase 4:**
- `ReplyRenderType` enum stays in `Text.kt` (no extraction; Android keeps
using its three modes internally).
- `ReplyNoteComposition` unchanged (still uses Android's own `NoteCompose`).
- Custom-emoji rendering in the "Replying to @X" line via `ReplyInfoMention`:
Android loses this momentarily. Flagged in PR body as known regression
with follow-up.
Verify:
- `./gradlew :amethyst:compileDebugKotlin` succeeds.
- Run Android app, confirm reply rows in home feed still show "replying
to @X".
### Phase 5 — Tests, Polish, Format
**Goal:** unit-test the new detection logic, run formatter, ready for review.
Files:
- `commons/src/jvmTest/.../ReplyContextTest.kt` — new test file with cases
exercising `ReplyContext.from(event, cache)`:
- kind 1 with reply marker → `parentNoteId` matches the marked tag.
- kind 1 with root + reply markers → `parentNoteId` matches reply, not
root.
- kind 1 single unmarked e tag → `parentNoteId` matches (positional).
- kind 1111 with `e` tag → `parentNoteId` matches.
- kind 1111 with `a` tag → `parentNoteId == null` (addressable).
- Non-reply kind 1 → returns null.
- Reply with parent NOT in cache and no NIP-22 `replyAuthor()` → returns
null (eventual consistency — pops in on parent arrival).
- Reply with parent in cache → `parentAuthorPubKey` matches parent's
`pubKey`, `parentAuthorDisplay` from User metadata if loaded.
Test placement rationale: lives in `commons/` because that's where
`ReplyContext` and its `from()` function live. Pattern follows existing
commons test conventions.
**Skipped:** commons `ReplyToLabelTest` smoke test — testing a 2-`Text`
composable provides no real coverage; logic lives in `ReplyContext.from()`
which has its own test.
Run:
- `./gradlew :commons:jvmTest --tests "*ReplyContext*"`
- `./gradlew spotlessApply`
## System-Wide Impact
### Interaction Graph
- `FeedScreen` LazyColumn item → reads note + parent flowSet metadata →
recomposes on either change → calls `toNoteDisplayData(cache)` → resolves
parent via `getNoteIfExists` → constructs `NoteDisplayData``NoteCard`
branches on `replyContext` → renders `QuotedNoteEmbed` + `ReplyToLabel`.
- Click on `ReplyToLabel` → invokes `onNavigateToProfile(parentPubKey)`
`DeckLayout` pushes profile column.
- Click on `QuotedNoteEmbed` → invokes `onNavigateToThread(parentNoteId)`
`DeckLayout` pushes thread column (existing behavior).
### State Lifecycle Risks
- **Parent fetched-then-evicted from cache.** `LargeSoftCache` is a soft
reference cache; under memory pressure the parent `Note` could be evicted
between `getNoteIfExists` calls. Impact: `QuotedNoteEmbed` falls back to its
"Loading quoted note..." card briefly until re-fetched. Mitigation: the
parent flow observation re-triggers fetch on next recomposition.
Acceptable.
- **Reply detection wrong for thread root.** A note with only a `root`
marker (no `reply` marker) is a reply to the root. `replyingToAddressOrEvent`
already returns the root in that case — covered.
### Error & Failure Propagation
- `replyingToAddressOrEvent()` returns null for non-replies → `resolveReplyContext`
returns null → no UI change. Safe.
- Malformed `e` tag (non-hex) → `toEventIdOrNull()` returns null →
`resolveReplyContext` returns null. Silent skip is correct.
- Missing parent + missing tag author pubkey → `resolveReplyContext` returns
null. Reply renders as regular post (current behavior, no regression).
### API Surface Parity
| Surface | Reply rendering today | After this change |
|---|---|---|
| Desktop home feed | None | Full (this PR) |
| Desktop hashtag / profile feeds | None | **Inherits**, since all desktop feeds funnel through `FeedScreen` + `NoteCard`. Verify in QA. |
| Desktop thread view | Already structural | Unchanged |
| Android home feed | Full | Unchanged (still works via re-pointed `ReplyToLabel`) |
### Integration Test Scenarios
(Manual; documented under Testing.)
1. Post a fresh reply on Android → confirm appears with embed + label on
desktop home feed.
2. Reply via desktop to a note from another account → confirm embed appears
in own feed after relay round-trip.
3. NIP-22 kind 1111 reply on an article (kind 30023) → confirm
addressable-parent path renders label only (no embed in v1).
4. Scroll feed fast, parent loads after reply visible → confirm embed
"pops in" via recomposition without scroll-position jump.
5. Reposted reply (someone reposts another's reply) → confirm we don't
recursively double-wrap (only the outermost repost wrapper renders; inner
reply context not re-embedded inside the repost). v1 acceptable behavior.
## Acceptance Criteria
### Functional
- [ ] A reply note in the desktop home feed renders the embedded parent
card directly above the reply body.
- [ ] A reply note shows "replying to @displayName" below the parent embed
and above the reply body.
- [ ] Clicking the "@displayName" opens the parent author's profile.
- [ ] Clicking the embedded parent opens the parent's thread.
- [ ] If the parent event is not yet in cache, the label is rendered with
the parent author's display name (or truncated hex if metadata also
missing). No empty quoted card is rendered.
- [ ] When the parent event arrives via relay subscription, the embed
appears via recomposition without manual refresh.
- [ ] NIP-22 generic comments (kind 1111) get the same treatment as
NIP-10 kind-1 replies.
- [ ] Non-reply notes render identically to today (no regression).
- [ ] Reposts continue to render identically to today.
- [ ] Android home feed reply rendering still works (no regression after
switchover to shared `ReplyToLabel`).
### Non-Functional
- [ ] No measurable feed scroll-frame regression (visual sniff test on a
~500-item feed).
- [ ] No new unbounded subscriptions — parent fetches use the existing
batched missing-event-ID mechanism.
- [ ] Quote depth strictly = 1 (parent embed does not itself embed a
grandparent). Verified by unit test.
### Quality
- [ ] `./gradlew :commons:build` passes.
- [ ] `./gradlew :desktopApp:test` passes (including new tests).
- [ ] `./gradlew :amethyst:compileDebugKotlin` passes.
- [ ] `./gradlew spotlessApply` applied; no diff after.
- [ ] Manual UI checklist (above) passes on a real account.
## Testing
### Unit
```bash
./gradlew :desktopApp:test --tests "*EventExtensionsReplyContext*"
./gradlew :commons:jvmTest --tests "*ReplyToLabel*"
```
### Manual UI Checklist
1. Launch desktop app: `./gradlew :desktopApp:run`
2. Open home feed, scroll to a thread with replies.
3. Verify reply rows show parent embed + "replying to @X".
4. Click "@X" → profile opens in new deck column.
5. Click parent embed → thread opens in new deck column.
6. Force-fetch a reply whose parent is NOT yet cached (e.g. follow
someone whose reply targets an old root):
- confirm label shows immediately.
- confirm embed appears within a few seconds (after relay returns).
7. Open a feed of an account that uses kind 1111 (NIP-22) replies →
confirm same treatment.
8. Compare against Android side-by-side for the same account/relays.
9. Confirm a long thread doesn't recursively show grandparent (1-level
cap).
10. Confirm reposts don't visually regress.
## Dependencies & Risks
- **No new third-party deps.**
- **Depends on quartz's `replyingToAddressOrEvent()` being correct** — this
is already battle-tested on Android. Low risk.
- **Risk: Android emoji-in-reply-label regression** for users with custom
emoji in display names. Documented trade-off; follow-up to extract
`ReplyInfoMention` if regression confirmed.
- **Risk: `QuotedNoteEmbed` signature mismatch** — implementation phase
may need to refactor it to accept `NoteDisplayData`. Contained.
- **Risk: Address-based parents (`a` tag) for kind 30023 articles** — the
embed render path may not handle non-kind-1 parents gracefully. Mitigation:
when `ReplyContext.parentNoteId == null` (addressable), render label only
(skip the `QuotedNoteEmbed` branch). Spec'd in NoteCard render path.
## Sources & References
### Origin
- **Brainstorm:**
[docs/brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md](../brainstorms/2026-06-05-desktop-feed-reply-context-brainstorm.md)
— key decisions carried forward: FULL render mode, extract to commons now,
NIP-22 same treatment, 1-level depth, pre-compute in
`Event.toNoteDisplayData`.
### Internal
- Detection: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt:73-84`
- Android render: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt:82-124`
- Android `ReplyToLabel`: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt:121-142`
- Android `ReplyNoteComposition` (NOT extracted — for reference only):
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt:1491-1508`
- Desktop `NoteCard` + `NoteDisplayData`:
`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt:81-88, 488-535`
- Desktop converter: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt:32-53`
- Desktop cache: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:564-571`
- Repost pattern (blueprint): `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt:128-197`
- Best-name resolution: `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt:90`
- Compose Multiplatform string pattern: `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt:31-38`
### Worktree
- Path: `../AmethystMultiplatform-feed-reply-context`
- Branch: `fix/desktop-feed-reply-context`
- Base: `origin/main` @ `0874706b3`
## Unanswered Questions
### Resolved by deepen-plan research
- ✅ **`QuotedNoteEmbed` signature** — takes `(noteId: String, localCache, onMentionClick?, onNavigateToThread?)`. Handles loading state internally. We just pass `noteId`.
- ✅ **`replyingToAddressOrEvent()` return** — flat `String?`. Disambiguate event-id vs address by `":" in raw`.
- ✅ **NIP-22 addressable parent** — v1 = **label-only** (no embed). `QuotedNoteEmbed` doesn't accept `Address`. Follow-up.
- ✅ **Visual border**`MaterialTheme.colorScheme.outlineVariant` 1.dp + `RoundedCornerShape(8.dp)`. Already used in `ThreadScreen.kt:283`.
- ✅ **Translations** — leave Android translations until follow-up commons multi-locale consolidation. English-only key in commons `strings.xml`.
- ✅ **Reposted-reply behavior** — FeedScreen extracts inner note then renders full `NoteCard` on it. Our new reply-context auto-engages on the inner card. Acceptable; no extra work.
- ✅ **Hashtag / profile / bookmarks / search feeds** — all use `Event.toNoteDisplayData(localCache)` (9 verified call sites). Reply context auto-engages everywhere with no plumbing.
### Still open — flagged for implementation phase
- ⚠️ **Inline custom-emoji in "Replying to @X"** — Android currently uses `ReplyInfoMention` (lines 145-163) which renders emoji-aware display names via `CreateTextWithEmoji`. Our extracted `ReplyToLabel` takes a plain `String` for v1, losing emoji on Android. Trade-off: simpler API, faster ship. If regression complaints arrive, follow-up adds an emoji-aware overload.
- ⚠️ **`Note.flow()` performance with many feed items** — each LazyColumn item now creates and `collectAsState`s an extra StateFlow when it's a reply. For a 500-item feed where 50% are replies, that's ~250 extra collectors. Repost code already does this so it's the established pattern, but profile if scroll regresses.
- ⚠️ **Parent author availability when not in cache and event is not NIP-22**`ReplyContext.from()` returns null. Reply renders as a regular post until the parent event arrives via subscription, then recomposition picks it up. UX impact: brief moment where a reply looks like a regular post. Acceptable (better than guessing the wrong author from a `p` tag).
@@ -0,0 +1,523 @@
---
title: "fix(quartz): NIP-46 bunker double-resume + retry id-reuse races"
type: fix
status: completed
date: 2026-06-03
origin: docs/brainstorms/2026-06-03-nip46-bunker-double-resume-brainstorm.md
---
# fix(quartz): NIP-46 bunker double-resume + retry id-reuse races
## Revision Note (2026-06-03, post-review)
Three reviews (simplicity / architecture / pattern-recognition) converged on
pivoting the approach. Original plan used atomic-remove + `tryResume` on a
cached `Continuation` map. **Revised approach: Channel-per-request (per
retry attempt) + fresh `request.id` per attempt**, matching the de facto
Quartz convention used in `NostrClientPublishExt.kt` and 4 sibling files
under `quartz/.../accessories/`.
### Why the pivot
| Driver | Detail |
|---|---|
| **Architecture review finding** | Quartz already uses Channel-per-request as house style (5+ files). The cached-`Continuation` pattern in `RemoteSignerManager` / `IntentRequestManager` is the outlier — only 2 files. |
| **4th failure mode discovered** | `RemoteSignerManager.kt:74-101` retry loop reuses the same `request.id` across attempts. Late response from attempt 1 can resume attempt 2's continuation with **stale data** (correctness bug, not just crash). Investigation verdict: HIGH probability on flaky relays. Fresh-id-per-retry naturally fixes this. |
| **Simplicity review** | Pivoting eliminates the `@InternalCoroutinesApi` opt-in surface entirely, kills the Plan C / SingleShotContinuation alternative discussions, removes the `TestLogCapture` test-infra invention, and removes the Strategy B stress-loop test. |
| **Pattern review** | "Aligns the existing outlier with the convention" — fewer correlation styles in the codebase, not more. |
### Scope unchanged from original plan
- Both managers fixed in the same PR (NIP-46 + NIP-55 sibling).
- Function-level concurrency primitives — no public API change to
`launchWaitAndParse`. 17 call sites of `launchWaitAndParse` across
`NostrSignerRemote.kt`, `ForegroundRequestHandler.kt`, and the retry test
are untouched.
- `tryAndWait` (`ParallelUtils.kt:71-79`) is **retained** — still used by
`collectSuccessfulOperationsReturning` (`ParallelUtils.kt:98`). Only its
use inside the two managers is replaced.
---
## Overview
Fix two related correctness bugs in the NIP-46 bunker signer
(`RemoteSignerManager`) and its NIP-55 Android sibling
(`IntentRequestManager`):
1. **Double-resume crash**`Continuation.resume(...)` called twice for
the same id, throwing `IllegalStateException: Already resumed`.
Triggered by (a) multi-relay delivery, (b) bunker echo/retry,
(c) late response after `tryAndWait` timeout fires.
2. **Retry id-reuse → wrong-data bug** (NIP-46 only) — retry attempts
reuse the same `request.id`, so a late response from attempt N can
resume attempt N+1's continuation with attempt N's data.
The fix replaces the cached `Continuation<T>` map with a
**Channel-per-request** correlation pattern (mirroring the convention
in `quartz/.../accessories/NostrClientPublishExt.kt`), and regenerates
`request.id` per retry attempt.
## Problem Statement
### Bug 1 — Double-resume crash
```kotlin
// RemoteSignerManager.kt:46-50
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse) // ← unsafe
}
```
Non-atomic `get(id)?.resume(value)` — three races trigger double-resume:
| Race | Sequence | Result |
|---|---|---|
| **Late response after timeout** | `tryAndWait`'s `withTimeoutOrNull` completes continuation with `null`; bunker's actual response arrives ms later; `newResponse` calls `resume` on already-completed continuation. | `IllegalStateException` at `RemoteSignerManager.kt:49` |
| **Multi-relay delivery (NIP-46 only)** | `NostrSignerRemote.kt:82` fires `scope.launch { manager.newResponse(event) }` per delivered event, no dedupe. Two relays delivering same response → two concurrent `newResponse` calls → both `get` same continuation → both `resume`. | First wins, second throws. |
| **Bunker echo / retry (NIP-46 only)** | Some bunker servers re-publish on relay reconnect. Same as multi-relay but with longer time gap. | Second resume throws. |
Stack trace:
```
Exception in thread "DefaultDispatcher-worker-42" java.lang.IllegalStateException:
Already resumed, but proposed with update BunkerResponse@…
at kotlinx.coroutines.CancellableContinuationImpl.alreadyResumedError(CancellableContinuationImpl.kt:556)
at com.vitorpamplona.quartz.nip46RemoteSigner.signer.RemoteSignerManager.newResponse(RemoteSignerManager.kt:49)
at com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote$subscription$2$1.invokeSuspend(NostrSignerRemote.kt:83)
```
### Bug 2 — Retry id-reuse → wrong data (NIP-46 only)
```kotlin
// RemoteSignerManager.kt:66-101 (paraphrased)
val request = buildRequest(...) // ← request.id assigned ONCE
val event = signer.encrypt(request, ...) // event id derived once
var attempt = 0
while (true) {
val result = tryAndWait(timeout) { continuation ->
continuation.invokeOnCancellation { awaitingRequests.remove(request.id) }
awaitingRequests.put(request.id, continuation) // ← SAME id every attempt
client.publish(event, relayList = relayList)
}
when {
result != null -> return parser(result)
attempt >= maxRetries -> return SignerResult.RequestAddressed.TimedOut()
else -> { attempt++; delay(2_000L) }
}
}
```
Race sequence (default `timeout = 65_000L`):
```
T+0: Attempt 1: put(continuation_1, "req123")
T+65s: Attempt 1: timeout → invokeOnCancellation removes "req123"
T+67s: Attempt 2: put(continuation_2, "req123") ← same id
T+70s: Bunker's late response for ATTEMPT 1 arrives
newResponse() resumes continuation_2 with attempt_1's payload
⚠ wrong data delivered to caller
```
Likelihood: HIGH when relay RTT approaches timeout. Atomic-remove + `tryResume`
**does not fix this** — continuation_2 is genuinely live; `tryResume`
succeeds with stale data. Only fresh-id-per-attempt closes this race.
`IntentRequestManager.kt:119` already uses a fresh `RandomInstance.randomChars(32)` per
call and has no retry loop — Bug 2 does not apply there.
## Proposed Solution
### Mechanism: Channel-per-request
```kotlin
// after — RemoteSignerManager (paraphrased shape)
private val pending = ConcurrentHashMap<String, Channel<BunkerResponse>>()
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
// Atomic remove. Multi-relay / bunker-echo duplicates: losers see null.
val channel = pending.remove(bunkerResponse.id)
if (channel == null) {
Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate or unknown)" }
return
}
// capacity = 1: first delivery wins. trySend on a closed/full channel
// is a no-op — late response after timeout cannot crash.
channel.trySend(bunkerResponse)
}
private suspend fun <T : SignerResult.RequestAddressed> launchWaitAndParse(
request: BunkerRequest,
parser: (BunkerResponse) -> T,
): T {
var attempt = 0
while (true) {
// Fresh id per attempt: each attempt is a brand-new request to the bunker.
val attemptRequest = request.copy(id = RandomInstance.randomChars(32))
val event = signer.encrypt(attemptRequest, ...)
val channel = Channel<BunkerResponse>(capacity = 1)
pending[attemptRequest.id] = channel
try {
client.publish(event, relayList = relayList)
val response = withTimeoutOrNull(timeout) { channel.receive() }
when {
response != null -> return parser(response)
attempt >= maxRetries -> return SignerResult.RequestAddressed.TimedOut()
else -> { attempt++; delay(2_000L) }
}
} finally {
pending.remove(attemptRequest.id) // cleanup on both happy + timeout paths
channel.close()
}
}
}
```
### Mechanism applied to `IntentRequestManager`
Same shape, no retry loop, single attempt — `IntentResult` instead of
`BunkerResponse`, `LruCache` becomes `ConcurrentHashMap` (which is also
the structure used by the existing Channel-per-request files), and the
log tag becomes `"NIP55"`.
### Why this fix
| Property | Cached `Continuation` (today) | Atomic `remove` + `tryResume` (original plan) | Channel-per-request (this plan) |
|---|---|---|---|
| Double-resume crash | ❌ | ✅ | ✅ |
| Late response after timeout | ❌ | ✅ (`tryResume` returns null) | ✅ (`trySend` on closed channel is a no-op) |
| Multi-relay delivery | ❌ | ✅ (atomic remove) | ✅ (atomic remove) |
| Bunker echo / retry | ❌ | ✅ | ✅ |
| **Retry id-reuse → wrong data** | ❌ | ❌ | ✅ (fresh id per attempt) |
| `@InternalCoroutinesApi` | n/a | required | not required |
| House style match | outlier | outlier (still cached map) | ✅ matches `accessories/` convention |
| Memory leak on success path | leaks | incidentally fixed | fixed (finally block) |
## Technical Considerations
### Channel capacity = 1
Each request has at most one valid response (NIP-46 `auth_url` flow not
implemented in Amethyst — see "NIP-46 spec" below). `Channel(capacity = 1)`:
- First `trySend` succeeds → `receive` resumes with the value.
- Concurrent / late `trySend` after the channel has been drained returns
a `ChannelResult.Closed` once the `finally` block calls `close()`. No-op,
no throw.
- `withTimeoutOrNull(timeout) { channel.receive() }` returns `null` on
timeout; the `finally` block cleans up the map entry and closes the
channel.
### Fresh `request.id` per retry attempt
NIP-46 has no idempotency contract — each retry is a new request from the
bunker's perspective. Generating a fresh id per attempt is consistent
with the spec and is also what `IntentRequestManager` already does for
single attempts.
The user-visible cost: a slow bunker that finishes processing attempt 1
mid-way through attempt 2 will not have its attempt-1 work "rescued" —
the response is discarded and attempt 2's response is the one we return.
This is the **correct** behaviour; the alternative (rescuing attempt 1
into attempt 2's slot) is the very bug we're fixing.
### `IntentRequestManager.LruCache``ConcurrentHashMap`
`androidx.collection.LruCache` was sized at 2000 for bounded growth.
With the `finally`-block cleanup the map shrinks on every completed call,
so an unbounded `ConcurrentHashMap` matches the Quartz `accessories/`
convention without leak risk. (If we ever want a safety cap, `Caffeine`
is in the dependency graph already, but YAGNI.)
### NIP-46 spec: `auth_url` (multi-response per id)
Spec allows a second response per id when the bunker emits an `auth_url`
challenge first. **Amethyst has zero `auth_url` handling code today**;
every parser maps any non-null `error` to `Rejected`. Channel(capacity=1)
mirrors that current contract: first response is terminal. If `auth_url`
support is added later, the right fix is at the parser /
`launchWaitAndParse` layer (e.g., keep the channel open until the parser
returns `SignerResult.AwaitingAuth`, then `receive` again). Not in scope
here.
### `tryAndWait` retained for `collectSuccessfulOperationsReturning`
`tryAndWait` is still used by `ParallelUtils.kt:98`
(`collectSuccessfulOperationsReturning`). We are not deleting it —
only its uses inside the two managers go away.
### Performance implications
None measurable. One `Channel(1)` allocation + one entry in
`ConcurrentHashMap` per request, both freed in the `finally` block.
NIP-46 throughput is < 10 req/s in practice; this is noise.
### Security considerations
None. Thread-safety + correctness only; no protocol surface change,
no new trust assumptions, no new data exposed.
## System-Wide Impact
- **Interaction graph:** Relay → `INostrClient.subscribe`
`NostrSignerRemote` callback → `scope.launch``manager.newResponse`
channel `trySend``launchWaitAndParse`'s `receive` unblocks →
parser returns to Amethyst UI. The fix sits at the correlation layer;
everything downstream is unchanged. Upstream (`withTimeoutOrNull`, the
subscription callback) is unchanged.
- **`launchWaitAndParse` public signature unchanged.** All 17 call sites
(8 in `NostrSignerRemote.kt`, 9 in `ForegroundRequestHandler.kt`,
4 in tests) are untouched.
- **State lifecycle:** `pending` is the only mutable state. Atomic
`ConcurrentHashMap.put / remove` semantics + `finally`-block cleanup
guarantee no entries leak.
- **Error propagation:** Currently the `IllegalStateException` is thrown
on a `Dispatchers.Default` worker inside `scope.launch { ... }`. After
fix, no exception path remains — duplicates trigger a debug log line.
- **Integration test scenarios** (manual / amy):
1. **NIP-46 cold-start with multi-relay delivery:** subscribe on N=5 relays → send request → all 5 deliver same response → only one resume, no crash.
2. **NIP-46 late-response after timeout:** request with `timeout=100ms` against a bunker that responds at 200ms → returns `TimedOut`, debug log fires.
3. **NIP-46 retry loop with mid-flight stale response:** force `timeout < network RTT` (`timeout=100ms`, RTT=300ms) → attempt 1 times out, attempt 2 in flight, attempt 1's actual response arrives → silently discarded; attempt 2's eventual response is what we return.
4. **NIP-55 multi-result Intent:** Android signer returns Intent with multiple `results` entries that defensively repeat the same id → no crash.
## Acceptance Criteria
### Functional
- [x] `RemoteSignerManager.newResponse` never throws — late responses, duplicates from multiple relays, and bunker echoes are all silently dropped after a debug log.
- [x] `IntentRequestManager.newResponse` has the equivalent guarantee.
- [x] Each retry attempt in `RemoteSignerManager.launchWaitAndParse` uses a fresh `request.id`. A late response from attempt N cannot resume attempt N+1.
- [x] `pending` map entries are always removed in a `finally` block — no leaks on success, timeout, or thrown exception.
- [x] Caller-visible behaviour of `launchWaitAndParse` is unchanged in the happy path: same return value, same retry semantics, same `SignerResult.RequestAddressed` shapes.
### Non-functional
- [x] No `@OptIn(InternalCoroutinesApi::class)` introduced.
- [x] Channel capacity = 1; channel scoped to a single retry attempt (created inside loop, closed in `finally`).
### Quality gates
- [x] `./gradlew :quartz:compileKotlinJvm :quartz:compileKotlinAndroid` passes.
- [x] `./gradlew :quartz:jvmTest --tests "*RemoteSignerManager*"` passes, including new race-condition tests.
- [x] `./gradlew spotlessApply` clean before commit.
- [x] Existing `RemoteSignerManagerRetryTest` (5 tests) still passes.
- [x] Three new tests added covering: (a) duplicate response → no crash + one successful resume; (b) late response after timeout → no crash + caller sees `TimedOut`; (c) cross-attempt stale-response → attempt 1's late response does NOT corrupt attempt 2's result.
## Implementation Plan
### File-level changes
```
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/
└── RemoteSignerManager.kt # MODIFY
quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/
└── IntentRequestManager.kt # MODIFY
quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/
└── RemoteSignerManagerRetryTest.kt # MODIFY (add three tests)
```
### Step 1 — `RemoteSignerManager.kt`
Replace `awaitingRequests` cache + `tryAndWait`-based loop with
Channel-per-attempt + fresh id:
- Cache type: `ConcurrentHashMap<String, Channel<BunkerResponse>>`
- `newResponse`: atomic `remove` + `trySend` on `Channel(1)` (no-op on closed/full).
- `launchWaitAndParse`: inside the retry loop, copy request with fresh
`request.id` via `RandomInstance.randomChars(32)`, create
`Channel<BunkerResponse>(capacity = 1)`, register under the new id,
`client.publish`, then
`withTimeoutOrNull(timeout) { channel.receive() }`. Cleanup in `finally`.
Imports added:
- `kotlinx.coroutines.channels.Channel`
- `kotlinx.coroutines.withTimeoutOrNull`
- `com.vitorpamplona.quartz.utils.RandomInstance`
- `java.util.concurrent.ConcurrentHashMap`
- `com.vitorpamplona.quartz.utils.Log`
Imports removed:
- `kotlin.coroutines.Continuation`
- `kotlin.coroutines.resume`
- `com.vitorpamplona.quartz.utils.cache.LargeCache`
- `com.vitorpamplona.quartz.utils.tryAndWait` (no longer used here; keep in `ParallelUtils.kt`)
### Step 2 — `IntentRequestManager.kt`
Same shape, no retry loop, single attempt. `LruCache``ConcurrentHashMap`,
`Continuation``Channel<IntentResult>(capacity = 1)`, atomic `remove` +
`trySend`. Log tag `"NIP55"`. The `forEach` over multi-result Intents
now does `pending.remove(id)?.trySend(result)` per entry.
### Step 3 — Tests (`RemoteSignerManagerRetryTest.kt`)
Three new tests. All use the existing `runTest`-based scaffolding —
no new test infra (no `TestLogCapture`, no `Dispatchers.Default` stress
loops). Each test must be observable via the public `launchWaitAndParse`
return value, not internal log state.
```kotlin
@Test
fun `duplicate response events do not crash and resume once`() = runTest {
val client = TestClient()
val manager = RemoteSignerManager(timeout = 5000L, client = client, ...)
val resultDeferred = async { manager.launchWaitAndParse(...) }
runCurrent()
val response = client.captureRequestEvent().toResponse()
// Three deliveries of the same response — only the first should reach the caller.
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
advanceUntilIdle()
val result = resultDeferred.await()
assertIs<SignerResult.RequestAddressed.Result<*>>(result)
// Bug exists on main: second/third launch throws IllegalStateException → fails test.
}
@Test
fun `late response after timeout is silently discarded`() = runTest {
val client = TestClient(neverResponds = true)
val manager = RemoteSignerManager(timeout = 100L, maxRetries = 0, client = client, ...)
val resultDeferred = async { manager.launchWaitAndParse(...) }
val response = client.captureRequestEvent().toResponse()
advanceTimeBy(200L) // timeout fires
val result = resultDeferred.await()
assertIs<SignerResult.RequestAddressed.TimedOut>(result)
// Now the late response arrives — must not crash, must not affect caller.
manager.newResponse(response)
advanceUntilIdle()
// No assertion needed: lack of crash + caller already got TimedOut is the success criterion.
}
@Test
fun `late response from attempt 1 does not corrupt attempt 2 result`() = runTest {
val client = TestClient()
val manager = RemoteSignerManager(timeout = 100L, maxRetries = 1, client = client, ...)
val resultDeferred = async { manager.launchWaitAndParse(buildRequest("PAYLOAD_A")) }
runCurrent()
val attempt1Event = client.captureRequestEvent() // captures id_1
advanceTimeBy(150L) // attempt 1 times out + delay(2000) begins
advanceTimeBy(2000L) // retry kicks in
val attempt2Event = client.captureRequestEvent() // captures id_2 — must be different from id_1
assertNotEquals(attempt1Event.requestId, attempt2Event.requestId)
// Late response for attempt 1 arrives while attempt 2 is in flight
manager.newResponse(attempt1Event.toResponse(payload = "STALE_A"))
// Real response for attempt 2 arrives
manager.newResponse(attempt2Event.toResponse(payload = "FRESH_B"))
advanceUntilIdle()
val result = resultDeferred.await()
assertIs<SignerResult.RequestAddressed.Result<*>>(result)
assertEquals("FRESH_B", (result as SignerResult.RequestAddressed.Result<*>).value)
// On main: result would be "STALE_A" (Bug 2) — test fails. Also Bug 1 would crash.
}
```
### Step 4 — Verify on `main` first
Before applying Step 1, run the three new tests against `main`. Expected
failures:
- Test 1: `IllegalStateException: Already resumed` from second/third
`launch { newResponse }`.
- Test 2: `IllegalStateException` from the late `newResponse` call.
- Test 3: either `IllegalStateException` (Bug 1) or `assertEquals` failure
with `actual = "STALE_A"` (Bug 2). Both branches prove the test exercises
the race.
Apply Step 1, re-run, all three pass.
### Step 5 — Format + final build
```bash
./gradlew spotlessApply
./gradlew :quartz:build
```
## Success Metrics
- Zero `IllegalStateException: Already resumed` log entries from
`RemoteSignerManager.newResponse` or `IntentRequestManager.newResponse`
after the fix lands.
- Zero stale-data correctness reports tied to retry id reuse.
- Three new tests in `RemoteSignerManagerRetryTest` pass; same tests fail
on `main`.
- No regressions in the existing 5 retry tests.
## Dependencies & Risks
| Item | Risk | Mitigation |
|---|---|---|
| Channel migration changes the correlation primitive | If a future feature needs multi-response per id (e.g., NIP-46 `auth_url`), the channel must be re-`receive`'d after the parser yields `AwaitingAuth` | Not blocking today (no `auth_url` code). Documented as the right architectural seam. |
| Fresh id per retry attempt changes wire behaviour | Bunkers that cache responses by id will not see the second request as a duplicate | Acceptable — NIP-46 has no idempotency contract; this matches `IntentRequestManager`'s existing behaviour. |
| `LruCache` (Android) → `ConcurrentHashMap` change | `LruCache` had a 2000-entry cap; `ConcurrentHashMap` is unbounded | `finally`-block cleanup guarantees entries shrink on every completed call. Mirrors the unbounded `ConcurrentHashMap` already used in `quartz/.../accessories/`. |
| Existing 5 retry tests rely on the old continuation-cache API | Tests may not compile against the new `pending` map | Tests should only interact through the public `launchWaitAndParse` + `newResponse` surface (verified in deepen-plan research). If any directly inspect `awaitingRequests`, update to inspect `pending` or pivot to result-based assertion. |
## Alternative Approaches Considered
1. **Atomic `remove` + `tryResume` on cached `CancellableContinuation`** (original plan)
Fixes Bug 1 cleanly but leaves Bug 2 (retry id-reuse) intact. Pulls in
`@InternalCoroutinesApi`. Stays with the outlier pattern. Rejected
after architecture + simplicity reviews.
2. **try/catch `IllegalStateException` around `resume`**
Anti-pattern; doesn't fix Bug 2. Rejected.
3. **`SingleShotContinuation<T>` wrapper helper**
Reusable abstraction for a problem already solved by Channel. Doesn't
fix Bug 2. YAGNI; rejected.
4. **Subscription-level dedupe in `NostrSignerRemote.kt:82`**
Bounded LRU of seen event ids. Suppresses duplicate work upstream. Not
needed because Channel `trySend` on capacity-1 is already O(1) and
guard at the receive side is atomic. Deferred unless field telemetry
shows high duplicate volume.
5. **Fix only `RemoteSignerManager`, leave `IntentRequestManager`**
Sibling bug is identical; bundling is cheaper than a follow-up.
6. **Keep `tryAndWait` for the managers**
Rejected because `tryAndWait`'s `CancellableContinuation`-cache
pattern is the source of both bugs. Keeping `tryAndWait` for
`collectSuccessfulOperationsReturning` is fine — different use case
(no shared id, no concurrent multi-source delivery).
## Sources & References
### Origin
- **Brainstorm:** [`docs/brainstorms/2026-06-03-nip46-bunker-double-resume-brainstorm.md`](../../docs/brainstorms/2026-06-03-nip46-bunker-double-resume-brainstorm.md)
- Decisions changed during deepen-plan + review pass:
- Fix mechanism: atomic remove + `tryResume` → Channel-per-request
- Scope: added Bug 2 (retry id-reuse) as in-scope after investigation
showed HIGH probability of reaching it on flaky relays
### Internal references
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt:44,46-50,66-101` — the two bug sites
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt:69-86` — subscription handler (line 82 = source of multi-relay concurrent calls)
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ParallelUtils.kt:71-79,98``tryAndWait` (retained for `collectSuccessfulOperationsReturning`)
- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt` — house-style reference for Channel-per-request
- `quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/IntentRequestManager.kt:63,80-97,119,126` — sibling bug + existing fresh-id usage at line 119
- `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt` — existing test scaffolding to extend
### External references
- [NIP-46 spec](https://github.com/nostr-protocol/nips/blob/master/46.md) — request/response shapes + the `auth_url` challenge flow
- [kotlinx.coroutines `Channel`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-channel/) — capacity, `trySend`, `receive`, `close` semantics
- [`runTest` docs](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-test.html) — virtual time + single-threaded dispatcher
---
## Unanswered Questions
- `pending` final naming — `pending` vs `awaitingRequests` (retain old name for grep continuity)? — minor; leaning `pending` (matches `accessories/` convention)
- Whether the NIP-55 Intent multi-result branch is ever actually hit in practice — defensive fix either way; if telemetry confirms it's dead code we could collapse to single-result path in follow-up
- Whether to grep existing tests for direct `awaitingRequests` access before Step 1 — yes, do it at the start of /ce:work to flag breakage early
@@ -22,47 +22,37 @@ package com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip55AndroidSigner.api.IResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult
import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
/**
* This class manages the lifecycle of foreground signing requests in a NIP-55 compliant Android signer flow.
* Manages the lifecycle of foreground signing requests in a NIP-55 compliant Android signer flow.
*
* - It tracks pending signing requests using a unique ID and allows for awaiting their results via coroutines.
* - Tracks pending signing requests by a unique call id via a per-request [Channel].
* - Provides a way to launch foreground Intents (to request user approval) and wait for the response.
* - Handles timeouts on user approval using [tryAndWait].
* - Handles approval timeouts via [withTimeoutOrNull].
* - Collects results via [newResponse] when the user responds to the foreground request.
*
* Main components:
* Key usage flow: request initiated -> store channel by id -> launch intent -> withTimeoutOrNull
* receive -> finally cleanup. User responds -> atomic remove + trySend on the channel.
*
* - `awaitingRequests`: LRU cache mapping request IDs to continuations for async response handling.
* - `appLauncher`: Function reference to launch foreground Intents (typically provided by an Activity).
* - `launchAndWait`: Suspend function to send an Intent, wait for an answer, and parse the result.
* - `newResponse`: Handles incoming results from the foreground activity using a unique ID.
*
* Key usage flows:
*
* - Request initiated -> store continuation by ID -> launch intent -> wait
* - User responds -> resume continuation -> remove ID -> return parsed result
*
* The class also cleans up pending requests on timeout or cancellation.
* Duplicate, unknown, or late deliveries (e.g. if the activity surfaces multiple results for the
* same id) atomically read out as null and are dropped after a debug log line no continuation
* is ever resumed twice.
*/
class IntentRequestManager(
val foregroundApprovalTimeout: Long = 30000,
) {
val activityNotFoundIntent = Intent()
// LRU cache to store pending requests and their continuations.
private val awaitingRequests = LruCache<String, Continuation<IntentResult>>(2000)
private val pending = LargeCache<String, Channel<IntentResult>>()
// Function to launch an Intent in the foreground.
private var appLauncher: ((Intent) -> Unit)? = null
/** Call this function when the launcher becomes available on activity, fragment or compose */
@@ -82,70 +72,66 @@ class IntentRequestManager(
if (results != null) {
// This happens when the intent responds to many requests at the same time.
IntentResult.fromJsonArray(results).forEach { result ->
if (result.id != null) {
awaitingRequests[result.id]?.resume(result)
awaitingRequests.remove(result.id)
}
if (result.id != null) dispatch(result.id, result)
}
} else {
val result = IntentResult.fromIntent(data)
if (result.id != null) {
awaitingRequests[result.id]?.resume(result)
awaitingRequests.remove(result.id)
}
if (result.id != null) dispatch(result.id, result)
}
}
private fun dispatch(
id: String,
result: IntentResult,
) {
val channel = pending.remove(id)
if (channel == null) {
Log.d("NIP55") { "no channel for intent result id=$id (duplicate, unknown, or late)" }
return
}
channel.trySend(result)
}
fun hasForegroundActivity() = appLauncher != null
/**
* Launches the signer, waits and parses the result
* Launches the signer, waits and parses the result.
*
* @param requestIntent The Intent to be launched.
* @param requestIntentBuilder Builder for the Intent to be launched.
* @param parser A function that parses the response Intent into a [SignerResult.RequestAddressed<T>].
* @return The result after parsing the Intent using the provided parser.
*
* This function uses the [tryAndWait] utility to implement a timeout on the foreground approval.
* It assigns a unique ID to the request and keeps a continuation to resume once the result is received.
* If the timeout occurs or the continuation is cancelled, the request ID is cleaned up from [awaitingRequests].
* Flags are added to the Intent to ensure it is brought to the front if already running.
*/
suspend fun <T : IResult> launchWaitAndParse(
requestIntentBuilder: () -> Intent,
parser: (intent: IntentResult) -> SignerResult.RequestAddressed<T>,
): SignerResult.RequestAddressed<T> =
appLauncher?.let { launcher ->
val requestIntent = requestIntentBuilder()
val callId = RandomInstance.randomChars(32)
): SignerResult.RequestAddressed<T> {
val launcher = appLauncher ?: return SignerResult.RequestAddressed.NoActivityToLaunchFrom()
requestIntent.putExtra("id", callId)
requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val requestIntent = requestIntentBuilder()
val callId = RandomInstance.randomChars(32)
requestIntent.putExtra("id", callId)
requestIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val channel = Channel<IntentResult>(capacity = 1)
pending.put(callId, channel)
return try {
try {
val resultIntent =
tryAndWait(foregroundApprovalTimeout) { continuation ->
continuation.invokeOnCancellation {
awaitingRequests.remove(callId)
}
awaitingRequests.put(callId, continuation)
try {
launcher.invoke(requestIntent)
} catch (e: Exception) {
Log.e("ExternalSigner", "Error launching intent", e)
awaitingRequests.remove(callId)
throw e
}
}
when (resultIntent) {
null -> SignerResult.RequestAddressed.TimedOut()
else -> parser(resultIntent)
}
launcher.invoke(requestIntent)
} catch (e: ActivityNotFoundException) {
Log.e("ExternalSigner", "Error launching intent: Signer not found", e)
SignerResult.RequestAddressed.SignerNotFound()
return SignerResult.RequestAddressed.SignerNotFound()
}
} ?: SignerResult.RequestAddressed.NoActivityToLaunchFrom()
val resultIntent = withTimeoutOrNull(foregroundApprovalTimeout) { channel.receive() }
when (resultIntent) {
null -> SignerResult.RequestAddressed.TimedOut()
else -> parser(resultIntent)
}
} finally {
pending.remove(callId)
channel.close()
}
}
}
@@ -0,0 +1,97 @@
/*
* 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.quartz.experimental.agora
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
/**
* Agora fundraiser / crowdfunding campaign (kind 33863).
*
* An app-specific addressable kind used by the Agora client (built on the Ditto
* stack). It is **not** defined by any NIP; the schema below is derived from
* events seen in the wild. It reuses standard NIP tags wherever possible
* (`title`, `banner`, `imeta`, `t` hashtags, `published_at`) plus a few
* fundraiser-specific tags:
*
* - `goal` fundraising target, integer sats.
* - `deadline` unix-seconds deadline to reach the goal.
* - `w` one or more on-chain donation addresses (Bitcoin / silent
* payment). Display/copy only; Amethyst does not send on-chain.
*
* Amethyst renders progress from zaps to this event (it cannot observe on-chain
* donations), so the progress bar reflects Lightning support, not the full
* amount raised.
*/
@Immutable
class FundraiserEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun banner() = tags.firstNotNullOfOrNull(BannerTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
/** Best image to show for the campaign: explicit banner, else an `image` tag. */
fun coverImage() = banner() ?: image()
/** Fundraising target in sats, from the `goal` tag. */
fun goal() = tags.firstTagValueAsLong("goal")
/** Deadline (unix seconds) from the `deadline` tag. */
fun deadline() = tags.firstTagValueAsLong("deadline")
/**
* On-chain donation addresses from the `w` tags (Bitcoin / silent payment;
* may be empty). Display/copy only Amethyst does not send on-chain.
*/
fun wallets() = tags.mapValueTagged("w") { it }
fun topics() = tags.hashtags()
override fun publishedAt(): Long? {
val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse) ?: return null
// ignore timestamps in the future
return if (publishedAt <= createdAt) publishedAt else null
}
companion object {
const val KIND = 33863
const val ALT_DESCRIPTION = "Fundraiser campaign"
}
}
@@ -0,0 +1,70 @@
/*
* 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.quartz.experimental.birdstar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
/**
* Birdstar "Birdex" species collection (kind 12473).
*
* An app-specific **replaceable** kind published by the Birdstar app
* (`birdstar.app`) a birdwatching life-list. It is **not** defined by any NIP;
* the schema below is derived from events seen in the wild. Being replaceable,
* each author keeps a single, latest Birdex.
*
* The event carries no body and no images its payload is the species list,
* one entry per observed species, as alternating tags:
*
* - `n` the species' scientific name (e.g. `Icterus galbula`).
* - `i` an external identity reference for the species, a Wikidata entity URL
* (NIP-73 style, e.g. `https://www.wikidata.org/entity/Q805774`).
* - `alt` a human-readable summary written by the publisher
* (e.g. `Birdex: 24 species`).
*
* Amethyst renders a minimal, fixed-size summary card from [speciesNames] and
* [speciesCount]; it does not resolve the Wikidata references to images.
*/
@Immutable
class BirdexEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** Scientific names of the collected species, in event order, from the `n` tags. */
fun speciesNames() = tags.mapValueTagged("n") { it }
/** Number of collected species (one `n` tag per species). */
fun speciesCount() = speciesNames().size
/** Publisher-provided human-readable summary, from the `alt` tag (may be null). */
fun summary() = tags.firstTagValue("alt")
companion object {
const val KIND = 12473
}
}
@@ -0,0 +1,86 @@
/*
* 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.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.nullable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
/**
* Tolerant serializer for the kind-0 `birthday` field.
*
* NIP-24 defines `birthday` as an object `{ "year", "month", "day" }` (each field
* optional). Some clients (e.g. Ditto / divine.video) instead write a string such
* as `"10-24"`, which is not spec-compliant. With the default serializer that type
* mismatch throws, and because [MetadataEvent.contactMetaData] turns any parse
* exception into `null`, a single malformed `birthday` would discard the **entire**
* profile (name, picture, about).
*
* This serializer parses the spec object form and treats anything else as absent
* (`null`) rather than failing, so one non-conformant field can no longer break
* profile rendering. The string form is intentionally not "recovered": the spec
* has no string format, and a bare `"10-24"` is ambiguous (MM-DD vs DD-MM).
*
* Modelled on [com.vitorpamplona.quartz.nip11RelayInfo.FlexibleIntListSerializer].
*/
object BirthdayTolerantSerializer : KSerializer<Birthday?> {
private val delegate = Birthday.serializer()
// Nullable serializer ⇒ nullable descriptor, so the framework's metadata stays
// honest even on code paths that consult it (e.g. coerceInputValues).
override val descriptor: SerialDescriptor = delegate.descriptor.nullable
override fun deserialize(decoder: Decoder): Birthday? {
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
val element = decoder.decodeJsonElement()
if (element !is JsonObject) {
// Non-spec birthday (e.g. Ditto's "10-24" string). Ignore it rather than
// failing the whole profile parse. Log the JSON kind only, not the raw
// (untrusted, network-sourced) value.
Log.w("BirthdayTolerantSerializer") { "Ignoring non-object birthday (${element::class.simpleName})" }
return null
}
return try {
decoder.json.decodeFromJsonElement(delegate, element)
} catch (e: Exception) {
Log.w("BirthdayTolerantSerializer") { "Ignoring malformed birthday object: ${e.message}" }
null
}
}
override fun serialize(
encoder: Encoder,
value: Birthday?,
) {
if (value == null) {
encoder.encodeNull()
} else {
delegate.serialize(encoder, value)
}
}
}
@@ -48,6 +48,8 @@ class UserMetadata {
var about: String? = null
var bot: Boolean? = null
var pronouns: String? = null
@Serializable(with = BirthdayTolerantSerializer::class)
var birthday: Birthday? = null
var nip05: String? = null
var domain: String? = null
@@ -27,4 +27,7 @@ interface BaseRepostEvent {
fun boostedEventId(): HexKey?
fun boostedAddress(): Address?
/** The kind of the reposted (boosted) event, as declared in the `k` tag. */
fun boostedKind(): Int?
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
@@ -77,6 +78,8 @@ class GenericRepostEvent(
fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId)
override fun boostedKind() = tags.lastNotNullOfOrNull(KindTag::parse)
fun originalAuthors() = tags.mapNotNull(PTag::parse)
fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey)
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
@@ -77,6 +78,8 @@ class RepostEvent(
fun boostedAddressIds() = tags.lastNotNullOfOrNull(ATag::parseAddressId)
override fun boostedKind() = tags.lastNotNullOfOrNull(KindTag::parse)
fun originalAuthors() = tags.mapNotNull(PTag::parse)
fun originalAuthorKeys() = tags.mapNotNull(PTag::parseKey)
@@ -27,11 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.cache.LargeCache
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlinx.coroutines.withTimeoutOrNull
class RemoteSignerManager(
val timeout: Long = 65_000,
@@ -41,19 +42,27 @@ class RemoteSignerManager(
val relayList: Set<NormalizedRelayUrl>,
val maxRetries: Int = 1,
) {
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
private val pending = LargeCache<String, Channel<BunkerResponse>>()
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
val channel = pending.remove(bunkerResponse.id)
if (channel == null) {
Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate, unknown, or late)" }
return
}
channel.trySend(bunkerResponse)
}
/**
* Launches the signer, waits and parses the result.
*
* Builds the request once and republishes the same event on retry to ensure
* the bunker's response (keyed by request ID) can always be matched.
* Each retry attempt uses a fresh request id so a late response from a previous
* attempt cannot resume the current attempt with stale data. The bunker request
* builder is still called only once per call; the manager rewrites the id per
* attempt internally.
*
* @param bunkerRequestBuilder The BunkerRequest to be sent.
* @param parser A function that parses the BunkerResponse into a [SignerResult.RequestAddressed<T>].
@@ -63,36 +72,38 @@ class RemoteSignerManager(
bunkerRequestBuilder: () -> BunkerRequest,
parser: (response: BunkerResponse) -> SignerResult.RequestAddressed<T>,
): SignerResult.RequestAddressed<T> {
val request = bunkerRequestBuilder()
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remoteKey,
signer = signer,
)
val template = bunkerRequestBuilder()
var attempt = 0
while (true) {
val result =
tryAndWait(timeout) { continuation ->
continuation.invokeOnCancellation {
awaitingRequests.remove(request.id)
}
val attemptRequest =
BunkerRequest(
id = RandomInstance.randomChars(32),
method = template.method,
params = template.params,
)
val event =
NostrConnectEvent.create(
message = attemptRequest,
remoteKey = remoteKey,
signer = signer,
)
awaitingRequests.put(request.id, continuation)
val channel = Channel<BunkerResponse>(capacity = 1)
pending.put(attemptRequest.id, channel)
val response =
try {
client.publish(event, relayList = relayList)
withTimeoutOrNull(timeout) { channel.receive() }
} finally {
pending.remove(attemptRequest.id)
channel.close()
}
when {
result != null -> {
return parser(result)
}
attempt >= maxRetries -> {
return SignerResult.RequestAddressed.TimedOut()
}
response != null -> return parser(response)
attempt >= maxRetries -> return SignerResult.RequestAddressed.TimedOut()
else -> {
attempt++
delay(2_000L)
@@ -22,12 +22,14 @@
package com.vitorpamplona.quartz.utils
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -340,6 +342,7 @@ class EventFactory {
AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BirdexEvent.KIND -> BirdexEvent(id, pubKey, createdAt, tags, content, sig)
BidEvent.KIND -> BidEvent(id, pubKey, createdAt, tags, content, sig)
BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig)
BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -418,6 +421,7 @@ class EventFactory {
FileStorageHeaderEvent.KIND -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig)
FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig)
FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig)
GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig)
GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig)
@@ -598,5 +602,16 @@ class EventFactory {
WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig)
} as T
/**
* True when [kind] maps to a typed Quartz event class either a
* compiled-in branch above or a registered [factories] builder. Unknown
* kinds are parsed as a bare [Event], so a probe instance's runtime type
* equals [Event] exactly when the kind has no dedicated class.
*
* Used to decide whether a repost's inner (boosted) kind is something
* Amethyst can parse and render at all.
*/
fun isKnownKind(kind: Int): Boolean = create<Event>("", "", 0L, kind, emptyArray(), "", "")::class != Event::class
}
}
@@ -0,0 +1,81 @@
/*
* 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.quartz.experimental.agora
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class FundraiserEventTest {
private fun sampleEvent(): Event =
EventFactory.create(
id = "45e3f48a12b461fc4684002136944ad061921368e993a832c506f0eebb5cf807",
pubKey = "652b1b36da75133890148f685af9038e1934e00a276815ac6b16d8b249d53de1",
createdAt = 1_780_094_572L,
kind = FundraiserEvent.KIND,
tags =
arrayOf(
arrayOf("d", "please-help-me-i-m-amira-from-gaza-i-need-your-help"),
arrayOf("title", "Please help me, I'm Amira from Gaza"),
arrayOf("banner", "https://blossom.primal.net/abc.jpg"),
arrayOf("goal", "10000"),
arrayOf("deadline", "9700905600"),
arrayOf("w", "bc1p2zmtzrlxde9zsd3fxhwf5fpxmutzzkvq7gnuwmxcans7wwxjee7s9dft56"),
arrayOf("w", "sp1qqfsmnyaerhghjceqfu95az6qfw0ugf4f"),
arrayOf("t", "community"),
arrayOf("t", "emergency"),
),
content = "Help Amira from Gaza continue her education.",
sig = "00".repeat(64),
)
@Test
fun factoryBuildsFundraiserForKind33863() {
val event = sampleEvent()
assertTrue(
event is FundraiserEvent,
"Expected a FundraiserEvent but got ${event::class.simpleName}",
)
}
@Test
fun kind33863IsNowKnown() {
assertTrue(EventFactory.isKnownKind(FundraiserEvent.KIND), "kind 33863 should be a known kind")
}
@Test
fun parsesFundraiserFields() {
val event = sampleEvent()
assertIs<FundraiserEvent>(event)
assertEquals("Please help me, I'm Amira from Gaza", event.title())
assertEquals("https://blossom.primal.net/abc.jpg", event.coverImage())
assertEquals(10000L, event.goal())
assertEquals(9700905600L, event.deadline())
assertEquals(2, event.wallets().size)
assertEquals("bc1p2zmtzrlxde9zsd3fxhwf5fpxmutzzkvq7gnuwmxcans7wwxjee7s9dft56", event.wallets().first())
assertEquals(listOf("community", "emergency"), event.topics())
assertEquals("please-help-me-i-m-amira-from-gaza-i-need-your-help", event.dTag())
}
}
@@ -0,0 +1,78 @@
/*
* 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.quartz.experimental.birdstar
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class BirdexEventTest {
private fun sampleEvent(): Event =
EventFactory.create(
id = "a099d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc",
pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d",
createdAt = 1_780_836_939L,
kind = BirdexEvent.KIND,
tags =
arrayOf(
arrayOf("alt", "Birdex: 3 species"),
arrayOf("i", "https://www.wikidata.org/entity/Q805774"),
arrayOf("n", "Icterus galbula"),
arrayOf("i", "https://www.wikidata.org/entity/Q738534"),
arrayOf("n", "Baeolophus bicolor"),
arrayOf("i", "https://www.wikidata.org/entity/Q829683"),
arrayOf("n", "Mimus polyglottos"),
arrayOf("client", "birdstar.app"),
),
content = "",
sig = "00".repeat(64),
)
@Test
fun factoryBuildsBirdexForKind12473() {
val event = sampleEvent()
assertTrue(
event is BirdexEvent,
"Expected a BirdexEvent but got ${event::class.simpleName}",
)
}
@Test
fun kind12473IsNowKnown() {
assertTrue(EventFactory.isKnownKind(BirdexEvent.KIND), "kind 12473 should be a known kind")
}
@Test
fun parsesBirdexFields() {
val event = sampleEvent()
assertIs<BirdexEvent>(event)
assertEquals(3, event.speciesCount())
assertEquals(
listOf("Icterus galbula", "Baeolophus bicolor", "Mimus polyglottos"),
event.speciesNames(),
)
assertEquals("Birdex: 3 species", event.summary())
}
}
@@ -0,0 +1,91 @@
/*
* 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.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BirthdayTolerantSerializerTest {
private fun metaWith(content: String): MetadataEvent =
EventFactory.create(
id = "ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c",
pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d",
createdAt = 1L,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = content,
sig = "00".repeat(64),
) as MetadataEvent
/**
* Regression for the Ditto / divine.video profile (npub1jvnpg4c, "MK Fain")
* whose `birthday` is the non-spec string "10-24". Before the tolerant
* serializer this threw and [MetadataEvent.contactMetaData] returned null,
* dropping the whole profile.
*/
@Test
fun stringBirthdayDoesNotDropTheProfile() {
val meta =
metaWith(
"""{"name":"MK Fain","about":"Team Soapbox","picture":"https://blossom.ditto.pub/x.jpg","nip05":"mk@ditto.pub","birthday":"10-24"}""",
).contactMetaData()
assertIs<UserMetadata>(meta, "profile must still parse despite the malformed birthday")
assertEquals("MK Fain", meta.name)
assertEquals("https://blossom.ditto.pub/x.jpg", meta.picture)
assertEquals("mk@ditto.pub", meta.nip05)
assertNull(meta.birthday, "non-object birthday must be ignored, not fatal")
}
@Test
fun otherNonObjectBirthdaysAreIgnored() {
// number, array, and JSON null are all non-spec for `birthday`.
listOf(
"""{"name":"A","birthday":1024}""",
"""{"name":"A","birthday":[10,24]}""",
"""{"name":"A","birthday":null}""",
).forEach { json ->
val meta = metaWith(json).contactMetaData()
assertIs<UserMetadata>(meta, "profile must survive birthday=$json")
assertEquals("A", meta.name)
assertNull(meta.birthday)
}
}
/**
* End-to-end check that a dropped birthday does not leak back into the
* serialized profile. The omission itself is the decoder's default-null
* suppression (encodeDefaults stays false on JsonMapper), not the serializer
* this just pins the real-world JsonMapper output.
*/
@Test
fun nullBirthdayIsOmittedOnSerialization() {
val meta = metaWith("""{"name":"A","birthday":"10-24"}""").contactMetaData()
assertIs<UserMetadata>(meta)
val serialized = JsonMapper.toJson(meta)
assertTrue("birthday" !in serialized, "a null birthday should not be serialized back out: $serialized")
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -31,21 +32,46 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
@OptIn(ExperimentalCoroutinesApi::class)
class RemoteSignerManagerRetryTest {
private val signer = NostrSignerInternal(KeyPair())
private val remoteKeyPair = KeyPair()
private val remoteKey = Hex.encode(remoteKeyPair.pubKey)
private val bunkerSigner = NostrSignerInternal(remoteKeyPair)
private val relay = NormalizedRelayUrl("wss://relay.test")
private suspend fun decodeRequestId(event: Event): String {
val plaintext = bunkerSigner.decrypt(event.content, event.pubKey)
val request = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(plaintext)
return request.id
}
private suspend fun bunkerPongFor(requestId: String): NostrConnectEvent =
NostrConnectEvent.create(
message = BunkerResponsePong(requestId),
remoteKey = signer.pubKey,
signer = bunkerSigner,
)
@Test
fun timeoutReturnsTimedOutAfterMaxRetries() =
runTest {
@@ -165,6 +191,190 @@ class RemoteSignerManagerRetryTest {
assertEquals(1, manager.maxRetries)
}
@Test
fun duplicateResponsesAreSafeAndResumeOnce() =
runTest {
val capturing = CapturingNostrClient()
val manager =
RemoteSignerManager(
timeout = 5_000,
client = capturing,
signer = signer,
remoteKey = remoteKey,
relayList = setOf(relay),
maxRetries = 0,
)
val deferred =
async {
manager.launchWaitAndParse(
bunkerRequestBuilder = { BunkerRequestPing() },
parser = PingResponse::parse,
)
}
runCurrent()
val publishedRequestId = decodeRequestId(capturing.publishedEvents.single())
val response = bunkerPongFor(publishedRequestId)
// Three deliveries of the same response — only one continuation exists,
// so the second and third would have crashed on the old `get(id)?.resume(...)`
// path. With atomic remove + Channel(1) trySend they are safe no-ops.
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
launch { manager.newResponse(response) }
advanceUntilIdle()
val result = deferred.await()
assertIs<SignerResult.RequestAddressed.Successful<PingResult>>(result)
}
@Test
fun lateResponseAfterTimeoutIsSilentlyDiscarded() =
runTest {
val capturing = CapturingNostrClient()
val manager =
RemoteSignerManager(
timeout = 100,
client = capturing,
signer = signer,
remoteKey = remoteKey,
relayList = setOf(relay),
maxRetries = 0,
)
val deferred =
async {
manager.launchWaitAndParse(
bunkerRequestBuilder = { BunkerRequestPing() },
parser = PingResponse::parse,
)
}
runCurrent()
val publishedRequestId = decodeRequestId(capturing.publishedEvents.single())
// Let the timeout fire. Caller resolves to TimedOut and the channel is closed.
val result = deferred.await()
assertIs<SignerResult.RequestAddressed.TimedOut<PingResult>>(result)
// Now the late response arrives. On the old `get(id)?.resume(...)` path the
// continuation was already completed by the timeout, so this would throw
// IllegalStateException("Already resumed"). With the fix the entry is gone
// from `pending` and trySend on the closed channel is a no-op.
val response = bunkerPongFor(publishedRequestId)
manager.newResponse(response)
advanceUntilIdle()
}
@Test
fun lateResponseFromAttempt1DoesNotCorruptAttempt2() =
runTest {
val capturing = CapturingNostrClient()
val manager =
RemoteSignerManager(
timeout = 100,
client = capturing,
signer = signer,
remoteKey = remoteKey,
relayList = setOf(relay),
maxRetries = 1,
)
val deferred =
async {
manager.launchWaitAndParse(
bunkerRequestBuilder = { BunkerRequestPing() },
parser = PingResponse::parse,
)
}
runCurrent()
// Attempt 1 publishes, then times out at T=100.
val attempt1Id = decodeRequestId(capturing.publishedEvents[0])
advanceTimeBy(150)
// delay(2_000) between attempts: attempt 2 starts at T=2_100.
// Land mid-window so attempt 2's own 100 ms timeout (T=2_200) hasn't fired yet.
advanceTimeBy(2_000)
runCurrent()
// Attempt 2 must use a different id — otherwise a late attempt-1 response
// could resume the attempt-2 channel with stale data.
assertEquals(2, capturing.publishedEvents.size)
val attempt2Id = decodeRequestId(capturing.publishedEvents[1])
assertNotEquals(attempt1Id, attempt2Id)
// Late delivery of attempt 1's response. With the fix it has no entry in
// `pending` and is silently discarded.
manager.newResponse(bunkerPongFor(attempt1Id))
// Attempt 2's real response.
manager.newResponse(bunkerPongFor(attempt2Id))
advanceUntilIdle()
val result = deferred.await()
val success = assertIs<SignerResult.RequestAddressed.Successful<PingResult>>(result)
assertEquals(attempt2Id, success.result.pong)
}
}
private class CapturingNostrClient : INostrClient {
val publishedEvents = mutableListOf<Event>()
override fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
override fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> = MutableStateFlow(emptySet())
override fun connect() {}
override fun disconnect() {}
override fun reconnect(
onlyIfChanged: Boolean,
ignoreRetryDelays: Boolean,
) {}
override fun isActive(): Boolean = false
override fun syncFilters(relay: IRelayClient) {}
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {}
override fun count(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {}
override fun unsubscribe(subId: String) {}
override fun publish(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
publishedEvents.add(event)
}
override fun pendingPublishRelaysFor(eventId: String): Set<NormalizedRelayUrl>? = null
override fun addConnectionListener(listener: RelayConnectionListener) {}
override fun removeConnectionListener(listener: RelayConnectionListener) {}
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun activeRequests(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun close() {}
}
private class CountingNostrClient(
@@ -0,0 +1,44 @@
/*
* 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.quartz.utils
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class EventFactoryIsKnownKindTest {
@Test
fun knownForTypedKinds() {
assertTrue(EventFactory.isKnownKind(TextNoteEvent.KIND), "kind ${TextNoteEvent.KIND} (text note) should be known")
assertTrue(EventFactory.isKnownKind(RepostEvent.KIND), "kind ${RepostEvent.KIND} (repost) should be known")
assertTrue(EventFactory.isKnownKind(GenericRepostEvent.KIND), "kind ${GenericRepostEvent.KIND} (generic repost) should be known")
}
@Test
fun unknownForUntypedKind() {
// 16767 is a Ditto-proprietary "Active profile theme" event with no Quartz class,
// so it is parsed as a bare Event and reported as not known.
assertFalse(EventFactory.isKnownKind(16767), "kind 16767 has no Quartz class and should be unknown")
}
}