diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1a99f3bf5f..de1e5ce5fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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() 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() 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() 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) { - nextToBeRemoved.forEach { note -> removeFromCache(note) } + fun unlinkAndRemove(nextToBeRemoved: List) { + nextToBeRemoved.forEach { note -> unlinkAndRemove(note) } } fun pruneExpiredEvents() { @@ -2836,16 +2834,16 @@ object LocalCache : ILocalCache, ICacheProvider { val childrenToBeRemoved = mutableListOf() 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") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index 77a390d21b..2cae673cab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -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 = 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) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 46cccd28ce..a9a1c43924 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -107,6 +107,7 @@ fun VideoViewInner( controllerVisible = controllerVisible, onDialog = onZoom, hasBlurhash = hasBlurhash, + isFullscreen = isFullscreen, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt new file mode 100644 index 0000000000..32585ceb51 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeControls.kt @@ -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(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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt new file mode 100644 index 0000000000..2e7999b072 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMath.kt @@ -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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index e53321661e..93ab093d99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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 } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt new file mode 100644 index 0000000000..9eb703c166 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullscreenSwipeMathTest.kt @@ -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)) + } +} diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index f8902f46e3..733b19cde6 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index cf17505499..88c122bfdd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -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") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index ebe9e27d02..7c61246550 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -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 { + fun clearChildLinks(): List { 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 { + 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) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt index 4c91551d71..9587b5943c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt @@ -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) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NotePruningReferenceTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NotePruningReferenceTest.kt new file mode 100644 index 0000000000..888957d103 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NotePruningReferenceTest.kt @@ -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) + } +}