mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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),
|
||||
|
||||
+7
@@ -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)
|
||||
}
|
||||
|
||||
+8
-3
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+121
-10
@@ -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) }
|
||||
}
|
||||
|
||||
+39
-3
@@ -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
|
||||
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
+173
@@ -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,
|
||||
|
||||
+5
-3
@@ -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(
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+8
-2
@@ -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 ||
|
||||
|
||||
+2
-4
@@ -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 ||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
|
||||
+7
-40
@@ -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()
|
||||
|
||||
+8
-2
@@ -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 ||
|
||||
|
||||
+6
-4
@@ -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 ||
|
||||
|
||||
+6
-4
@@ -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 ||
|
||||
|
||||
+161
@@ -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)
|
||||
}
|
||||
+1
@@ -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),
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+139
@@ -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>
|
||||
|
||||
+110
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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 Cooley–Tukey 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()
|
||||
}
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
+67
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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 cyan→magenta / 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
|
||||
+41
@@ -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
|
||||
}
|
||||
+37
@@ -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,
|
||||
)
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+128
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
+115
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
+79
@@ -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())
|
||||
}
|
||||
}
|
||||
+134
@@ -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
|
||||
}
|
||||
}
|
||||
+27
-5
@@ -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) {
|
||||
|
||||
+117
-4
@@ -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(
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+74
-1
@@ -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(
|
||||
|
||||
+141
@@ -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
|
||||
128–197). 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:488–535) 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:128–152, 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:419–429)
|
||||
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 145–163):** 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 419–429) 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 121–142). Keep `ReplyInfoMention` (lines 145–163) — 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
|
||||
+52
-66
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -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"
|
||||
}
|
||||
}
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+86
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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?
|
||||
}
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+39
-28
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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())
|
||||
}
|
||||
}
|
||||
+78
@@ -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())
|
||||
}
|
||||
}
|
||||
+91
@@ -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")
|
||||
}
|
||||
}
|
||||
+210
@@ -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(
|
||||
|
||||
+44
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user