mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ
This commit is contained in:
@@ -1336,54 +1336,28 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
/**
|
||||
* NIP-09 delete of a single targeted event.
|
||||
*
|
||||
* Removal has two halves: unlinking the note from everything that points AT it
|
||||
* (its parents, channels, and the per-user report/card/status/poll indexes —
|
||||
* all handled by [unlinkAndRemove]); and dealing with the note's OWN children
|
||||
* (the notes that point at IT). The delete path and the prune path share the
|
||||
* first half and differ only on the second:
|
||||
* - delete (here): the children are independent events and stay in the cache;
|
||||
* [Note.detachFromChildren] only severs their back-reference so the removed
|
||||
* shell can neither leak (held alive by a child's `replyTo`) nor be later
|
||||
* resurrected by `computeReplyTo` as a second Note for the same id.
|
||||
* - prune (see [unlinkAndRemove] callers): the whole child subtree is removed.
|
||||
*
|
||||
* Gift-wrapped events additionally drop their decrypted inner host.
|
||||
*/
|
||||
private fun deleteNote(deleteNote: Note) {
|
||||
val deletedEvent = deleteNote.event
|
||||
(deleteNote.event as? WrappedEvent)?.let { deleteWraps(it) }
|
||||
|
||||
if (deletedEvent is ReportEvent) {
|
||||
deletedEvent.reportedAuthor().forEach {
|
||||
getUserIfExists(it.pubkey)?.reportsOrNull()?.removeReport(deleteNote)
|
||||
}
|
||||
}
|
||||
deleteNote.detachFromChildren()
|
||||
|
||||
if (deleteNote is AddressableNote && deletedEvent is ContactCardEvent) {
|
||||
getUserIfExists(deletedEvent.aboutUser())?.cardsOrNull()?.removeCard(deleteNote)
|
||||
}
|
||||
|
||||
if (deleteNote is AddressableNote && deletedEvent is StatusEvent) {
|
||||
deleteNote.author?.statusStateOrNull()?.removeStatus(deleteNote)
|
||||
}
|
||||
|
||||
if (deletedEvent is PollResponseEvent) {
|
||||
deletedEvent.poll()?.eventId?.let {
|
||||
getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(deleteNote)
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedEvent is TorrentCommentEvent) {
|
||||
deletedEvent.torrentIds()?.let {
|
||||
getNoteIfExists(it)?.removeReply(deleteNote)
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedEvent is WrappedEvent) {
|
||||
deleteWraps(deletedEvent)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
deleteNote.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
deleteNote.inGatherers?.forEach { it.removeNote(deleteNote) }
|
||||
|
||||
getAnyChannel(deleteNote)?.removeNote(deleteNote)
|
||||
|
||||
notes.remove(deleteNote.idHex)
|
||||
|
||||
deleteNote.clearFlow()
|
||||
|
||||
refreshDeletedNoteObservers(deleteNote)
|
||||
unlinkAndRemove(deleteNote)
|
||||
}
|
||||
|
||||
fun deleteWraps(event: WrappedEvent) {
|
||||
@@ -2592,12 +2566,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
unlinkAndRemove(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
@@ -2631,12 +2605,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
unlinkAndRemove(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
// Audio-room presence is keyed separately from `notes` and
|
||||
// never gets reaped by the top-N rule. Drop entries older
|
||||
@@ -2676,12 +2650,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
toBeRemoved.forEach {
|
||||
childrenToBeRemoved.addAll(removeIfWrap(it))
|
||||
removeFromCache(it)
|
||||
unlinkAndRemove(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println(
|
||||
@@ -2699,8 +2673,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
if (noteEvent is WrappedEvent) {
|
||||
noteEvent.host?.id?.let {
|
||||
getNoteIfExists(it)?.let { it2 ->
|
||||
removeFromCache(it2)
|
||||
it2.removeAllChildNotes()
|
||||
unlinkAndRemove(it2)
|
||||
it2.clearChildLinks()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -2730,11 +2704,11 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
it.moveAllReferencesTo(newerVersion)
|
||||
}
|
||||
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
|
||||
@@ -2767,24 +2741,48 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeFromCache(note: Note) {
|
||||
/**
|
||||
* Unlinks [note] from everything in the cache that references it, then drops it
|
||||
* from the [notes] map and notifies observers. This is the shared "unlink from
|
||||
* above" half of removal, used by both the prune callers and [deleteNote].
|
||||
*
|
||||
* It detaches the note from:
|
||||
* - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps);
|
||||
* because event-level reports and torrent comments both carry the target in
|
||||
* `replyTo`, [Note.removeNote] cleans those up here too;
|
||||
* - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote`
|
||||
* always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders
|
||||
* resolve so a note can never linger in a channel after leaving the cache);
|
||||
* - the per-target indexes `replyTo` does NOT reach: user-level reports and
|
||||
* reported addresses, contact cards, statuses, and poll responses.
|
||||
*
|
||||
* It deliberately does NOT touch the note's own children: prune callers collect
|
||||
* them via [Note.clearChildLinks] and remove the subtree, while [deleteNote]
|
||||
* keeps them and severs only their back-reference. Every per-target removal is
|
||||
* idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an
|
||||
* event-level report reachable both ways) is harmless. Addressable notes are
|
||||
* dropped from the [addressables] map by the caller; this only removes from [notes].
|
||||
*/
|
||||
private fun unlinkAndRemove(note: Note) {
|
||||
note.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeNote(note)
|
||||
}
|
||||
|
||||
note.inGatherers?.forEach { it.removeNote(note) }
|
||||
|
||||
getAnyChannel(note)?.removeNote(note)
|
||||
|
||||
val noteEvent = note.event
|
||||
|
||||
if (noteEvent is ReportEvent) {
|
||||
@@ -2822,8 +2820,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
refreshDeletedNoteObservers(note)
|
||||
}
|
||||
|
||||
fun removeFromCache(nextToBeRemoved: List<Note>) {
|
||||
nextToBeRemoved.forEach { note -> removeFromCache(note) }
|
||||
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
|
||||
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
|
||||
}
|
||||
|
||||
fun pruneExpiredEvents() {
|
||||
@@ -2836,16 +2834,16 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
versionsToBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
addressesToBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
|
||||
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
|
||||
@@ -2863,11 +2861,11 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
toBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
|
||||
+50
-1
@@ -20,11 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -34,18 +37,23 @@ import androidx.compose.ui.geometry.Size
|
||||
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.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.ContentFrame
|
||||
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeControlsState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeLevelIndicator
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
internal const val SKIP_SECONDS = 10
|
||||
@@ -87,6 +95,7 @@ fun RenderVideoPlayer(
|
||||
onDialog: (() -> Unit)? = null,
|
||||
controllerVisible: MutableState<Boolean> = remember { mutableStateOf(false) },
|
||||
hasBlurhash: Boolean = false,
|
||||
isFullscreen: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// Hold the container size in a non-state holder so layout passes don't trigger an
|
||||
@@ -95,6 +104,29 @@ fun RenderVideoPlayer(
|
||||
val containerWidth = remember { intArrayOf(0) }
|
||||
val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) }
|
||||
|
||||
val swipeState = remember { FullscreenSwipeControlsState() }
|
||||
val context = LocalContext.current
|
||||
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager }
|
||||
// Brightness is applied to the fullscreen dialog window so it auto-reverts on dismiss.
|
||||
// Returns null for the inline feed player (not inside a dialog) — fine, gated by isFullscreen below.
|
||||
val dialogWindow = getDialogWindow()
|
||||
|
||||
// Sync the per-video mute (Media3 player volume) with the volume swipe. Mirrors the mute
|
||||
// button's handler exactly, so its listener-driven icon flips when the swipe mutes/unmutes,
|
||||
// and the global default carries to the next video.
|
||||
val isVideoMuted = { controllerState.controller.volume < 0.001f }
|
||||
val setVideoMuted = { mute: Boolean ->
|
||||
DEFAULT_MUTED_SETTING.value = mute
|
||||
controllerState.controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: clear any brightness override when this player leaves composition, so
|
||||
// exiting fullscreen never leaves the screen dimmed. releaseBrightness no-ops when dialogWindow
|
||||
// is null (the inline feed path) or when no override was applied, so this is safe unconditionally.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { swipeState.releaseBrightness(dialogWindow) }
|
||||
}
|
||||
|
||||
WatchPlaybackErrors(controllerState)
|
||||
|
||||
Box(
|
||||
@@ -115,7 +147,20 @@ fun RenderVideoPlayer(
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
}.then(
|
||||
if (isFullscreen) {
|
||||
Modifier.fullscreenSwipeControls(
|
||||
state = swipeState,
|
||||
audioManager = audioManager,
|
||||
window = dialogWindow,
|
||||
resolver = context.contentResolver,
|
||||
isMuted = isVideoMuted,
|
||||
setMuted = setVideoMuted,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
ContentFrame(
|
||||
player = controllerState.controller,
|
||||
@@ -172,5 +217,9 @@ fun RenderVideoPlayer(
|
||||
isLiveStream = isLive,
|
||||
)
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
FullscreenSwipeLevelIndicator(swipeState, isMuted = isVideoMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -107,6 +107,7 @@ fun VideoViewInner(
|
||||
controllerVisible = controllerVisible,
|
||||
onDialog = onZoom,
|
||||
hasBlurhash = hasBlurhash,
|
||||
isFullscreen = isFullscreen,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* 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.composable.controls
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.media.AudioManager
|
||||
import android.provider.Settings
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class SwipeAxis { Brightness, Volume }
|
||||
|
||||
private const val BRIGHTNESS_FLOOR = 0.01f
|
||||
private const val AUTO_HIDE_MILLIS = 800L
|
||||
|
||||
/**
|
||||
* Holds the live state for the fullscreen brightness/volume swipe. A single instance is remembered
|
||||
* by RenderVideoPlayer and shared between the drag [Modifier] and the [FullscreenSwipeLevelIndicator]
|
||||
* overlay. All device side-effects (AudioManager / window brightness) are applied here.
|
||||
*/
|
||||
class FullscreenSwipeControlsState {
|
||||
var axis by mutableStateOf<SwipeAxis?>(null)
|
||||
private set
|
||||
var level by mutableFloatStateOf(0f)
|
||||
private set
|
||||
var visible by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
// Bumped on every drag event and on drag end; the overlay keys its auto-hide timer on this so
|
||||
// the timer restarts while dragging and fires AUTO_HIDE_MILLIS after the last event.
|
||||
var interactionId by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
private var dragStartLevel = 0f
|
||||
private var accumulatedDragPx = 0f
|
||||
|
||||
// Max volume is device-invariant; cache it at drag start so the per-frame drag path doesn't
|
||||
// re-query AudioManager on every pointer event.
|
||||
private var maxVolume = 0
|
||||
|
||||
// Last discrete value actually pushed to the device this drag. Continuous finger movement maps
|
||||
// to the same volume index / brightness step across several frames; skipping unchanged writes
|
||||
// avoids redundant AudioManager calls and window-attribute relayouts with no visible effect.
|
||||
private var lastVolumeIndex = -1
|
||||
private var lastBrightnessStep = -1
|
||||
|
||||
fun startDrag(
|
||||
axis: SwipeAxis,
|
||||
audioManager: AudioManager?,
|
||||
window: Window?,
|
||||
resolver: ContentResolver,
|
||||
) {
|
||||
this.axis = axis
|
||||
accumulatedDragPx = 0f
|
||||
when (axis) {
|
||||
SwipeAxis.Volume -> {
|
||||
maxVolume = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC) ?: 0
|
||||
lastVolumeIndex = -1
|
||||
dragStartLevel = currentVolumeFraction(audioManager, maxVolume)
|
||||
}
|
||||
SwipeAxis.Brightness -> {
|
||||
lastBrightnessStep = -1
|
||||
dragStartLevel = currentBrightnessFraction(window, resolver)
|
||||
}
|
||||
}
|
||||
level = dragStartLevel
|
||||
visible = true
|
||||
interactionId++
|
||||
}
|
||||
|
||||
fun onDrag(
|
||||
dragAmountPx: Float,
|
||||
heightPx: Float,
|
||||
audioManager: AudioManager?,
|
||||
window: Window?,
|
||||
isMuted: () -> Boolean,
|
||||
setMuted: (Boolean) -> Unit,
|
||||
) {
|
||||
accumulatedDragPx += dragAmountPx
|
||||
level = computeLevel(dragStartLevel, accumulatedDragPx, heightPx)
|
||||
when (axis) {
|
||||
SwipeAxis.Volume -> {
|
||||
audioManager?.let { applyVolumeIfChanged(it) }
|
||||
applyMuteSync(isMuted, setMuted)
|
||||
}
|
||||
SwipeAxis.Brightness -> window?.let { applyBrightnessIfChanged(it) }
|
||||
null -> Unit
|
||||
}
|
||||
interactionId++
|
||||
}
|
||||
|
||||
// Directional mute sync: dragging up unmutes a muted video; reaching zero mutes it. Kept outside
|
||||
// the AudioManager branch so per-video mute still tracks the gesture even if device volume is
|
||||
// unavailable.
|
||||
private fun applyMuteSync(
|
||||
isMuted: () -> Boolean,
|
||||
setMuted: (Boolean) -> Unit,
|
||||
) {
|
||||
when (muteActionFor(level, movedUp = accumulatedDragPx < 0f, isMuted = isMuted())) {
|
||||
MuteAction.Mute -> setMuted(true)
|
||||
MuteAction.Unmute -> setMuted(false)
|
||||
MuteAction.None -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyVolumeIfChanged(audioManager: AudioManager) {
|
||||
if (maxVolume <= 0) return
|
||||
val index = levelToVolumeIndex(level, maxVolume)
|
||||
if (index == lastVolumeIndex) return
|
||||
lastVolumeIndex = index
|
||||
// Flag 0 = no system volume UI; we draw our own ring.
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0)
|
||||
}
|
||||
|
||||
private fun applyBrightnessIfChanged(window: Window) {
|
||||
val target = level.coerceIn(BRIGHTNESS_FLOOR, 1f)
|
||||
// Quantize to the panel's 0..255 range; sub-step movement is imperceptible and would
|
||||
// otherwise reassign window.attributes (a relayout) every frame for no visible change.
|
||||
val step = (target * 255f).roundToInt()
|
||||
if (step == lastBrightnessStep) return
|
||||
lastBrightnessStep = step
|
||||
applyBrightness(window, target)
|
||||
}
|
||||
|
||||
fun endDrag() {
|
||||
interactionId++
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
visible = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any brightness override this controller applied, restoring the window to the system
|
||||
* brightness. Call from the fullscreen player's onDispose so leaving fullscreen never leaves
|
||||
* the screen dimmed.
|
||||
*/
|
||||
fun releaseBrightness(window: Window?) {
|
||||
window?.let { releaseBrightnessOverride(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentVolumeFraction(
|
||||
audioManager: AudioManager?,
|
||||
max: Int,
|
||||
): Float {
|
||||
audioManager ?: return 0f
|
||||
if (max <= 0) return 0f
|
||||
return audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
|
||||
}
|
||||
|
||||
private fun currentBrightnessFraction(
|
||||
window: Window?,
|
||||
resolver: ContentResolver,
|
||||
): Float {
|
||||
val override = window?.attributes?.screenBrightness ?: -1f
|
||||
if (override in 0f..1f) return override
|
||||
val system =
|
||||
try {
|
||||
Settings.System.getInt(resolver, Settings.System.SCREEN_BRIGHTNESS)
|
||||
} catch (e: Settings.SettingNotFoundException) {
|
||||
128
|
||||
}
|
||||
return (system / 255f).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun applyBrightness(
|
||||
window: Window,
|
||||
brightness: Float,
|
||||
) {
|
||||
val params = window.attributes
|
||||
params.screenBrightness = brightness
|
||||
window.attributes = params
|
||||
}
|
||||
|
||||
private fun releaseBrightnessOverride(window: Window) {
|
||||
val params = window.attributes
|
||||
params.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
window.attributes = params
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical-drag handler for the fullscreen video surface. Left half of the surface controls
|
||||
* brightness, right half controls volume. Must be a separate [pointerInput] from the existing
|
||||
* tap/double-tap handler so taps still work.
|
||||
*/
|
||||
fun Modifier.fullscreenSwipeControls(
|
||||
state: FullscreenSwipeControlsState,
|
||||
audioManager: AudioManager?,
|
||||
window: Window?,
|
||||
resolver: ContentResolver,
|
||||
isMuted: () -> Boolean,
|
||||
setMuted: (Boolean) -> Unit,
|
||||
): Modifier =
|
||||
// isMuted/setMuted are intentionally not pointerInput keys: they change identity every
|
||||
// recomposition but read live state, so adding them would restart the gesture for no reason.
|
||||
pointerInput(state, audioManager, window, resolver) {
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { offset ->
|
||||
val axis = if (offset.x < size.width / 2f) SwipeAxis.Brightness else SwipeAxis.Volume
|
||||
state.startDrag(axis, audioManager, window, resolver)
|
||||
},
|
||||
onVerticalDrag = { _, dragAmount ->
|
||||
state.onDrag(dragAmount, size.height.toFloat(), audioManager, window, isMuted, setMuted)
|
||||
},
|
||||
onDragEnd = { state.endDrag() },
|
||||
onDragCancel = { state.endDrag() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Centered ring + glyph that appears while swiping and fades out shortly after the drag ends. */
|
||||
@Composable
|
||||
fun BoxScope.FullscreenSwipeLevelIndicator(
|
||||
state: FullscreenSwipeControlsState,
|
||||
isMuted: () -> Boolean,
|
||||
) {
|
||||
// Launch once on the stable state and watch interactionId via a snapshotFlow instead of keying
|
||||
// the effect on it: collectLatest restarts the auto-hide delay on each drag event, and reading
|
||||
// interactionId here (not as a composition key) avoids re-keying the effect every frame. The
|
||||
// indicator still recomposes per frame to redraw the arc as level changes — fine for a transient
|
||||
// drag overlay.
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.interactionId }
|
||||
.collectLatest {
|
||||
if (state.visible) {
|
||||
delay(AUTO_HIDE_MILLIS)
|
||||
state.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val alpha by animateFloatAsState(if (state.visible) 1f else 0f, label = "swipeIndicatorAlpha")
|
||||
if (alpha <= 0f) return
|
||||
|
||||
val axis = state.axis ?: return
|
||||
val level = state.level
|
||||
val ringColor = MaterialTheme.colorScheme.onBackground
|
||||
val trackColor = ringColor.copy(alpha = 0.25f)
|
||||
val backdrop = MaterialTheme.colorScheme.background.copy(alpha = 0.5f)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(110.dp)
|
||||
.alpha(alpha)
|
||||
.clip(CircleShape)
|
||||
.background(backdrop),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
val stroke = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round)
|
||||
drawArc(
|
||||
color = trackColor,
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
style = stroke,
|
||||
)
|
||||
drawArc(
|
||||
color = ringColor,
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f * level.coerceIn(0f, 1f),
|
||||
useCenter = false,
|
||||
style = stroke,
|
||||
)
|
||||
}
|
||||
|
||||
val symbol =
|
||||
when (axis) {
|
||||
SwipeAxis.Brightness -> MaterialSymbols.BrightnessMedium
|
||||
SwipeAxis.Volume ->
|
||||
if (isMuted() || level <= 0f) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp
|
||||
}
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
tint = ringColor,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.composable.controls
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Maps a vertical drag to a 0..1 level. Dragging up (negative accumulated pixels) increases the
|
||||
* level; dragging down decreases it. A drag spanning the full element height covers the entire
|
||||
* 0..1 range. The result is clamped to 0..1.
|
||||
*/
|
||||
fun computeLevel(
|
||||
startLevel: Float,
|
||||
accumulatedDragPx: Float,
|
||||
heightPx: Float,
|
||||
): Float {
|
||||
if (heightPx <= 0f) return startLevel.coerceIn(0f, 1f)
|
||||
return (startLevel - accumulatedDragPx / heightPx).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Clamps [level] to 0..1, then rounds it to a discrete stream-volume index in 0..max. Returns 0 when max <= 0. */
|
||||
fun levelToVolumeIndex(
|
||||
level: Float,
|
||||
max: Int,
|
||||
): Int {
|
||||
if (max <= 0) return 0
|
||||
return (level.coerceIn(0f, 1f) * max).roundToInt()
|
||||
}
|
||||
|
||||
/** The mute change a volume swipe should trigger on the per-video player. */
|
||||
enum class MuteAction { Mute, Unmute, None }
|
||||
|
||||
/**
|
||||
* Directional mute sync for the volume swipe.
|
||||
*
|
||||
* - Reaching zero mutes the video (no-op if already muted).
|
||||
* - Dragging the finger up ([movedUp], i.e. net upward from where the drag started) unmutes a muted
|
||||
* video — so a muted video pinned at max device volume still unmutes even though [level] can't rise.
|
||||
* - A downward swipe that stays above zero leaves the mute state untouched.
|
||||
*
|
||||
* [movedUp] is the drag direction, not a level comparison, so the clamp at level 1.0 doesn't swallow
|
||||
* the unmute intent.
|
||||
*/
|
||||
fun muteActionFor(
|
||||
level: Float,
|
||||
movedUp: Boolean,
|
||||
isMuted: Boolean,
|
||||
): MuteAction =
|
||||
when {
|
||||
level <= 0f -> if (isMuted) MuteAction.None else MuteAction.Mute
|
||||
isMuted && movedUp -> MuteAction.Unmute
|
||||
else -> MuteAction.None
|
||||
}
|
||||
+6
@@ -191,12 +191,18 @@ fun ZoomableImageDialog(
|
||||
val dialogWindow = getDialogWindow()
|
||||
|
||||
if (activityWindow != null && dialogWindow != null) {
|
||||
// Preserve any brightness override already applied to the dialog window (e.g. by the
|
||||
// fullscreen swipe controls). This block re-runs on recomposition (orientation change
|
||||
// re-reads `orientation` above), and copying the activity attributes would otherwise
|
||||
// reset screenBrightness and snap the user's brightness back mid-session.
|
||||
val currentBrightness = dialogWindow.attributes.screenBrightness
|
||||
val attributes = WindowManager.LayoutParams()
|
||||
attributes.copyFrom(activityWindow.attributes)
|
||||
attributes.type = dialogWindow.attributes.type
|
||||
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
|
||||
attributes.dimAmount = 0f
|
||||
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
|
||||
attributes.screenBrightness = currentBrightness
|
||||
dialogWindow.attributes = attributes
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.composable.controls
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class FullscreenSwipeMathTest {
|
||||
@Test
|
||||
fun dragUpIncreasesLevel() {
|
||||
// Drag up 500px (negative) on a 1000px screen from 0.2 -> +0.5 = 0.7
|
||||
assertEquals(0.7f, computeLevel(0.2f, -500f, 1000f), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dragDownDecreasesLevel() {
|
||||
assertEquals(0.3f, computeLevel(0.8f, 500f, 1000f), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampsToOne() {
|
||||
assertEquals(1f, computeLevel(0.9f, -500f, 1000f), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampsToZero() {
|
||||
assertEquals(0f, computeLevel(0.1f, 500f, 1000f), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroHeightReturnsStartClamped() {
|
||||
assertEquals(0.5f, computeLevel(0.5f, -100f, 0f), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroHeightClampsOutOfRangeStartLevel() {
|
||||
assertEquals(1f, computeLevel(1.5f, -100f, 0f), 0.0001f)
|
||||
assertEquals(0f, computeLevel(-0.3f, 100f, 0f), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun volumeIndexExactMidpoint() {
|
||||
assertEquals(5, levelToVolumeIndex(0.5f, 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun volumeIndexFull() {
|
||||
assertEquals(15, levelToVolumeIndex(1f, 15))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun volumeIndexZeroLevel() {
|
||||
assertEquals(0, levelToVolumeIndex(0f, 15))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun volumeIndexZeroMaxGuard() {
|
||||
assertEquals(0, levelToVolumeIndex(0.5f, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachingZeroWhileUnmutedMutes() {
|
||||
assertEquals(MuteAction.Mute, muteActionFor(level = 0f, movedUp = false, isMuted = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachingZeroWhileMutedIsNoop() {
|
||||
assertEquals(MuteAction.None, muteActionFor(level = 0f, movedUp = false, isMuted = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingUpWhileMutedUnmutes() {
|
||||
assertEquals(MuteAction.Unmute, muteActionFor(level = 0.5f, movedUp = true, isMuted = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingUpAtMaxWhileMutedStillUnmutes() {
|
||||
// Device volume pinned at 1.0 can't rise, but the upward drag still expresses intent.
|
||||
assertEquals(MuteAction.Unmute, muteActionFor(level = 1f, movedUp = true, isMuted = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingUpWhileUnmutedIsNoop() {
|
||||
assertEquals(MuteAction.None, muteActionFor(level = 0.5f, movedUp = true, isMuted = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingDownAboveZeroWhileMutedIsNoop() {
|
||||
assertEquals(MuteAction.None, muteActionFor(level = 0.5f, movedUp = false, isMuted = true))
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+1
@@ -46,6 +46,7 @@ object MaterialSymbols {
|
||||
val BookmarkAdd = MaterialSymbol("\uE598")
|
||||
val BookmarkBorder = MaterialSymbol("\uE8E7")
|
||||
val BookmarkRemove = MaterialSymbol("\uE59A")
|
||||
val BrightnessMedium = MaterialSymbol("\uE1AE")
|
||||
val Forward10 = MaterialSymbol("\uE056")
|
||||
val Replay10 = MaterialSymbol("\uE059")
|
||||
val CalendarMonth = MaterialSymbol("\uEBCC")
|
||||
|
||||
@@ -146,6 +146,8 @@ open class Note(
|
||||
removeZapPayment(note)
|
||||
removeReport(note)
|
||||
removeLabel(note)
|
||||
removeNutzap(note)
|
||||
removeOnchainZapBySource(note)
|
||||
}
|
||||
|
||||
var poll: PollResponsesCache? = null
|
||||
@@ -371,7 +373,7 @@ open class Note(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllChildNotes(): List<Note> {
|
||||
fun clearChildLinks(): List<Note> {
|
||||
val repliesChanged = replies.isNotEmpty()
|
||||
val reactionsChanged = reactions.isNotEmpty()
|
||||
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty()
|
||||
@@ -389,7 +391,8 @@ open class Note(
|
||||
zaps.values.filterNotNull() +
|
||||
zapPayments.keys +
|
||||
zapPayments.values.filterNotNull() +
|
||||
nutzaps.values.map { it.source }
|
||||
nutzaps.values.map { it.source } +
|
||||
onchainZaps.values.map { it.source }
|
||||
|
||||
replies = listOf()
|
||||
reactions = mapOf()
|
||||
@@ -414,6 +417,31 @@ open class Note(
|
||||
return toBeRemoved
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully detach this note from the notes below it in the graph so it can be
|
||||
* removed from the cache without leaving a partial deletion behind. It both
|
||||
* clears this note's own child collections (via [clearChildLinks]) and
|
||||
* drops this note from every child's [replyTo], so once this note leaves the
|
||||
* cache map nothing keeps the dead shell alive.
|
||||
*
|
||||
* This matters for the NIP-09 delete path: without severing the child →
|
||||
* parent `replyTo` links, the removed note leaks (held by each child) and a
|
||||
* later reply resolved through `computeReplyTo` would `getOrCreateNote` a
|
||||
* *second* Note for the same id — breaking the one-Note-per-id invariant.
|
||||
*
|
||||
* Returns the now-orphaned children (their other parents, if any, are kept).
|
||||
*/
|
||||
fun detachFromChildren(): List<Note> {
|
||||
val children = clearChildLinks()
|
||||
children.forEach { child ->
|
||||
val parents = child.replyTo
|
||||
if (parents != null && this in parents) {
|
||||
child.replyTo = parents - this
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
fun removeReaction(note: Note) {
|
||||
val tags = note.event?.tags ?: emptyArray()
|
||||
val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
@@ -587,6 +615,29 @@ open class Note(
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerRemoveOnchainZapBySource(source: Note): Boolean =
|
||||
syncLock.withLock {
|
||||
val newMap = onchainZaps.filterValues { it.source != source }
|
||||
if (newMap.size == onchainZaps.size) return@withLock false
|
||||
onchainZaps = newMap
|
||||
return@withLock true
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach every onchain-zap entry whose source is [source] — used when the
|
||||
* source OnchainZapEvent note is being pruned from `LocalCache`. Unlike
|
||||
* [removeOnchainZapForSource] (a verification verdict that respects the
|
||||
* anti-spoof / no-CONFIRMED-downgrade guards), this is an unconditional
|
||||
* cache-removal that must drop the strong reference no matter the status,
|
||||
* otherwise the pruned source Note leaks through this map.
|
||||
*/
|
||||
fun removeOnchainZapBySource(source: Note) {
|
||||
if (innerRemoveOnchainZapBySource(source)) {
|
||||
updateZapTotal()
|
||||
flowSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerAddNutzap(
|
||||
eventId: HexKey,
|
||||
entry: NutzapEntry,
|
||||
@@ -1165,6 +1216,21 @@ open class Note(
|
||||
note.addNutzap(it.source, it.claimedSats)
|
||||
it.source.replyTo = it.source.replyTo?.replace(this, note)
|
||||
}
|
||||
onchainZaps.forEach { (txid, entry) ->
|
||||
note.addOnchainZap(entry.source, txid, entry.claimedSats, entry.verifiedSats, entry.status)
|
||||
entry.source.replyTo = entry.source.replyTo?.replace(this, note)
|
||||
}
|
||||
zapPayments.forEach {
|
||||
note.addZapPayment(it.key, it.value)
|
||||
it.key.replyTo = it.key.replyTo?.replace(this, note)
|
||||
it.value?.replyTo = it.value?.replyTo?.replace(this, note)
|
||||
}
|
||||
labels.forEach { (hashtag, labelNotes) ->
|
||||
labelNotes.forEach {
|
||||
note.addLabel(hashtag, it)
|
||||
it.replyTo = it.replyTo?.replace(this, note)
|
||||
}
|
||||
}
|
||||
|
||||
replyTo = null
|
||||
replies = emptyList()
|
||||
@@ -1173,6 +1239,9 @@ open class Note(
|
||||
reports = emptyMap()
|
||||
zaps = emptyMap()
|
||||
nutzaps = emptyMap()
|
||||
onchainZaps = emptyMap()
|
||||
zapPayments = emptyMap()
|
||||
labels = emptyMap()
|
||||
zapsAmount = BigDecimal(0)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -349,15 +349,15 @@ class NoteOnchainZapTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeAllChildNotesClearsOnchainZapResolvedFlag() {
|
||||
// `removeAllChildNotes()` runs on delete-event handling and during cache
|
||||
fun clearChildLinksResetsOnchainZapResolvedFlag() {
|
||||
// `clearChildLinks()` runs on delete-event handling and during cache
|
||||
// pressure; the resolved flag must travel with the cleared state so a
|
||||
// re-arrival of the same event gets re-verified instead of being silently
|
||||
// skipped against stale state.
|
||||
val src = sourceNote("ee".repeat(32))
|
||||
src.onchainZapResolved = true
|
||||
|
||||
src.removeAllChildNotes()
|
||||
src.clearChildLinks()
|
||||
|
||||
assertFalse(src.onchainZapResolved)
|
||||
}
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Guards the cache-pruning invariant: when a Note is removed from `LocalCache`,
|
||||
* every other Note that referenced it must drop that strong reference. Onchain
|
||||
* zaps (NIP-BC) and nutzaps (NIP-61) were added to [Note] after the original
|
||||
* removal/migration routines were written, so these tests pin that
|
||||
* [Note.removeNote], [Note.clearChildLinks], and [Note.moveAllReferencesTo]
|
||||
* all account for them — otherwise a pruned source Note leaks through the
|
||||
* target's `onchainZaps` / `nutzaps` map and a duplicate Note with the same id
|
||||
* gets minted on the next relay echo.
|
||||
*/
|
||||
class NotePruningReferenceTest {
|
||||
private fun note(idHex: String) = Note(idHex)
|
||||
|
||||
private fun userFor(pubKey: HexKey) = User(pubKey) { addr -> Note(addr.toValue()) }
|
||||
|
||||
// A source-event Note with a wired author, matching the production shape where
|
||||
// `source.author` is the zap sender.
|
||||
private fun sourceNote(pubKey: HexKey): Note = note(pubKey).apply { author = userFor(pubKey) }
|
||||
|
||||
private fun eventWith(idHex: HexKey): Event =
|
||||
Event(
|
||||
id = idHex,
|
||||
pubKey = "ab".repeat(32),
|
||||
createdAt = 1L,
|
||||
kind = 9321,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
// ── Fix 1: removeNote drops onchain-zap and nutzap sources ──────────────
|
||||
|
||||
@Test
|
||||
fun removeNoteDetachesOnchainZapSource() {
|
||||
val target = note("a".repeat(64))
|
||||
val src = sourceNote("11".repeat(32))
|
||||
|
||||
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
|
||||
assertTrue(target.onchainZaps.containsKey("tx1"))
|
||||
|
||||
target.removeNote(src)
|
||||
|
||||
assertTrue(target.onchainZaps.isEmpty(), "onchain zap source must be detached on removeNote")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeNoteDetachesNutzapSource() {
|
||||
val target = note("b".repeat(64))
|
||||
val src = sourceNote("22".repeat(32)).apply { event = eventWith("cc".repeat(32)) }
|
||||
|
||||
target.addNutzap(src, claimedSats = 500L)
|
||||
assertTrue(target.nutzaps.isNotEmpty())
|
||||
|
||||
target.removeNote(src)
|
||||
|
||||
assertTrue(target.nutzaps.isEmpty(), "nutzap source must be detached on removeNote")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeOnchainZapBySourceIgnoresUnrelatedSource() {
|
||||
val target = note("d".repeat(64))
|
||||
val src = sourceNote("33".repeat(32))
|
||||
val other = sourceNote("44".repeat(32))
|
||||
|
||||
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
|
||||
|
||||
target.removeNote(other)
|
||||
|
||||
assertTrue(target.onchainZaps.containsKey("tx1"), "removing an unrelated note must not drop the entry")
|
||||
}
|
||||
|
||||
// ── Fix 2: clearChildLinks returns onchain-zap sources ──────────────
|
||||
|
||||
@Test
|
||||
fun clearChildLinksReturnsAndClearsOnchainZapSources() {
|
||||
val target = note("e".repeat(64))
|
||||
val src = sourceNote("55".repeat(32))
|
||||
|
||||
target.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
|
||||
|
||||
val removed = target.clearChildLinks()
|
||||
|
||||
assertTrue(src in removed, "onchain zap source must be returned for removal from the cache map")
|
||||
assertTrue(target.onchainZaps.isEmpty())
|
||||
}
|
||||
|
||||
// ── Fix 3: moveAllReferencesTo migrates onchain zaps, zap payments, labels ──
|
||||
|
||||
@Test
|
||||
fun moveAllReferencesToMigratesOnchainZaps() {
|
||||
val old = note("f0".repeat(32))
|
||||
val newer = AddressableNote(Address(30023, "ab".repeat(32), "slug"))
|
||||
val src = sourceNote("66".repeat(32)).apply { replyTo = listOf(old) }
|
||||
|
||||
old.addOnchainZap(src, "tx1", claimedSats = 1000L, verifiedSats = 1000L, status = OnchainZapStatus.CONFIRMED)
|
||||
|
||||
old.moveAllReferencesTo(newer)
|
||||
|
||||
assertTrue(old.onchainZaps.isEmpty(), "old version must release its onchain zaps")
|
||||
assertEquals(1, newer.onchainZaps.size, "onchain zap must move to the newer version")
|
||||
assertSame(src, newer.onchainZaps["tx1"]?.source)
|
||||
assertSame(newer, src.replyTo?.single(), "source replyTo must repoint to the newer version")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moveAllReferencesToMigratesZapPayments() {
|
||||
val old = note("f1".repeat(32))
|
||||
val newer = AddressableNote(Address(30023, "ab".repeat(32), "slug2"))
|
||||
val request = note("77".repeat(32)).apply { replyTo = listOf(old) }
|
||||
val response = note("88".repeat(32))
|
||||
|
||||
old.addZapPayment(request, response)
|
||||
|
||||
old.moveAllReferencesTo(newer)
|
||||
|
||||
assertTrue(old.zapPayments.isEmpty(), "old version must release its zap payments")
|
||||
assertTrue(newer.zapPayments.containsKey(request), "zap payment must move to the newer version")
|
||||
assertSame(newer, request.replyTo?.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moveAllReferencesToMigratesLabels() {
|
||||
val old = note("f2".repeat(32))
|
||||
val newer = AddressableNote(Address(30023, "ab".repeat(32), "slug3"))
|
||||
val labelNote = note("99".repeat(32)).apply { replyTo = listOf(old) }
|
||||
|
||||
old.addLabel("nostr", labelNote)
|
||||
|
||||
old.moveAllReferencesTo(newer)
|
||||
|
||||
assertTrue(old.labels.isEmpty(), "old version must release its labels")
|
||||
assertTrue(newer.labels["nostr"]?.contains(labelNote) == true, "label must move to the newer version")
|
||||
assertSame(newer, labelNote.replyTo?.single())
|
||||
}
|
||||
|
||||
// ── Fix 4: deleteNote severs child back-references (no partial deletion) ──
|
||||
|
||||
@Test
|
||||
fun detachFromChildrenSeversReplyToAndClearsCollections() {
|
||||
val parent = note("a1".repeat(32))
|
||||
val reply = note("b1".repeat(32)).apply { replyTo = listOf(parent) }
|
||||
parent.addReply(reply)
|
||||
|
||||
val detached = parent.detachFromChildren()
|
||||
|
||||
assertTrue(reply in detached, "the child must be returned as detached")
|
||||
assertTrue(parent.replies.isEmpty(), "parent must release its forward child references")
|
||||
assertTrue(
|
||||
reply.replyTo?.contains(parent) != true,
|
||||
"child must no longer point back at the removed parent",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detachFromChildrenKeepsOtherParents() {
|
||||
val deleted = note("a2".repeat(32))
|
||||
val survivor = note("c2".repeat(32))
|
||||
val reply = note("b2".repeat(32)).apply { replyTo = listOf(deleted, survivor) }
|
||||
deleted.addReply(reply)
|
||||
survivor.addReply(reply)
|
||||
|
||||
deleted.detachFromChildren()
|
||||
|
||||
assertEquals(listOf(survivor), reply.replyTo, "only the removed parent must be dropped from replyTo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detachFromChildrenSeversReactionAndZapSources() {
|
||||
val parent = note("a3".repeat(32))
|
||||
val reaction = sourceNote("31".repeat(32)).apply { replyTo = listOf(parent) }
|
||||
val zapSource = sourceNote("32".repeat(32)).apply { replyTo = listOf(parent) }
|
||||
|
||||
parent.addOnchainZap(zapSource, "tx1", claimedSats = 1L, verifiedSats = 1L, status = OnchainZapStatus.CONFIRMED)
|
||||
parent.addBoost(reaction)
|
||||
|
||||
parent.detachFromChildren()
|
||||
|
||||
assertTrue(parent.boosts.isEmpty())
|
||||
assertTrue(parent.onchainZaps.isEmpty())
|
||||
assertTrue(reaction.replyTo?.contains(parent) != true)
|
||||
assertTrue(zapSource.replyTo?.contains(parent) != true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user