mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/hopeful-brown-1suxdw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
+76
-28
@@ -54,12 +54,17 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -267,36 +272,47 @@ private fun DialogContent(
|
||||
.graphicsLayer {
|
||||
val src = sourceBounds
|
||||
val img = imageBounds
|
||||
if (src != null && img != null &&
|
||||
src.width > 0f && src.height > 0f &&
|
||||
img.width > 0f && img.height > 0f
|
||||
) {
|
||||
// Account for user-applied zoom: the image is rendered at
|
||||
// imageBounds transformed by the inner zoomable (uniform scale
|
||||
// around the layout center, then offset). The exit animation
|
||||
// must start from those visible bounds, not the unzoomed layout
|
||||
// bounds — otherwise dismissing a zoomed-in image jumps.
|
||||
val zoom = currentZoomState
|
||||
val zScale = zoom?.scale ?: 1f
|
||||
val zOffX = zoom?.offsetX ?: 0f
|
||||
val zOffY = zoom?.offsetY ?: 0f
|
||||
val imgCenter = img.center
|
||||
val zoomedWidth = img.width * zScale
|
||||
val zoomedHeight = img.height * zScale
|
||||
val zoomedCenterX = imgCenter.x + zOffX
|
||||
val zoomedCenterY = imgCenter.y + zOffY
|
||||
if (src != null && img != null && src.hasArea() && img.hasArea()) {
|
||||
// Account for user-applied zoom: the exit animation must start from
|
||||
// the visible bounds, not the unzoomed layout bounds — otherwise
|
||||
// dismissing a zoomed-in image jumps.
|
||||
val zoomed = img.zoomedBy(currentZoomState)
|
||||
// Uniform scale so non-square images keep their aspect ratio during
|
||||
// the grow animation. The image covers the source rect; the overflow
|
||||
// is clipped below.
|
||||
val startScale = coverScale(src, zoomed)
|
||||
val p = progress()
|
||||
val scale = lerp(startScale, 1f, p)
|
||||
val tx = lerp(src.center.x - startScale * zoomed.center.x, 0f, p)
|
||||
val ty = lerp(src.center.y - startScale * zoomed.center.y, 0f, p)
|
||||
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
// Uniform scale so non-square images keep their aspect ratio during
|
||||
// the grow animation. max() so the image covers the source rect in
|
||||
// at least one dimension; the other overflows centered on the tap.
|
||||
val startScale = maxOf(src.width / zoomedWidth, src.height / zoomedHeight)
|
||||
val srcCenter = src.center
|
||||
val p = progress()
|
||||
scaleX = lerp(startScale, 1f, p)
|
||||
scaleY = lerp(startScale, 1f, p)
|
||||
translationX = lerp(srcCenter.x - startScale * zoomedCenterX, 0f, p)
|
||||
translationY = lerp(srcCenter.y - startScale * zoomedCenterY, 0f, p)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = tx
|
||||
translationY = ty
|
||||
|
||||
if (p < 1f) {
|
||||
// The thumbnail may be a crop of the image (e.g. a square gallery
|
||||
// cell showing a 4:3 photo). Clip to a window that morphs from the
|
||||
// thumbnail's rect into the whole viewport, so the transition opens
|
||||
// from and closes into exactly what was on screen. The viewport (not
|
||||
// the measured image) is the end state so there is nothing to snap
|
||||
// when the clip turns off at p = 1, where pager neighbours must show.
|
||||
// Window coordinates, mapped back into this layer's pre-transform
|
||||
// space. A layer outline, not a draw-phase clip, so animating it
|
||||
// doesn't re-record the pager's display list every frame.
|
||||
shape =
|
||||
RectClipShape(
|
||||
Rect(
|
||||
left = (lerp(src.left, 0f, p) - tx) / scale,
|
||||
top = (lerp(src.top, 0f, p) - ty) / scale,
|
||||
right = (lerp(src.right, size.width, p) - tx) / scale,
|
||||
bottom = (lerp(src.bottom, size.height, p) - ty) / scale,
|
||||
),
|
||||
)
|
||||
clip = true
|
||||
}
|
||||
} else {
|
||||
// No source bounds: fall back to a plain fade.
|
||||
alpha = progress()
|
||||
@@ -441,6 +457,38 @@ internal suspend fun saveMediaToGallery(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Rect.hasArea() = width > 0f && height > 0f
|
||||
|
||||
/**
|
||||
* The image's on-screen bounds after the user's pinch zoom: the zoomable scales
|
||||
* uniformly around the layout center, then offsets.
|
||||
*/
|
||||
private fun Rect.zoomedBy(zoom: ZoomState?): Rect {
|
||||
val zScale = zoom?.scale ?: 1f
|
||||
val halfWidth = width * zScale / 2f
|
||||
val halfHeight = height * zScale / 2f
|
||||
val centerX = center.x + (zoom?.offsetX ?: 0f)
|
||||
val centerY = center.y + (zoom?.offsetY ?: 0f)
|
||||
return Rect(centerX - halfWidth, centerY - halfHeight, centerX + halfWidth, centerY + halfHeight)
|
||||
}
|
||||
|
||||
/** Uniform scale at which [image] covers [source] in both dimensions. */
|
||||
private fun coverScale(
|
||||
source: Rect,
|
||||
image: Rect,
|
||||
): Float = maxOf(source.width / image.width, source.height / image.height)
|
||||
|
||||
/** Clips a layer to a fixed [rect] in its own coordinates, regardless of the layer's size. */
|
||||
private class RectClipShape(
|
||||
private val rect: Rect,
|
||||
) : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline = Outline.Rectangle(rect)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(
|
||||
content: BaseMediaContent,
|
||||
|
||||
+30
-16
@@ -62,7 +62,13 @@ class ConcordChannelListFragmentationTest {
|
||||
salt: Int,
|
||||
): HexKey = (seed * 7919 + salt).toString(16).padStart(8, '0').repeat(8)
|
||||
|
||||
/** A realistically heavy membership: staff secrets, two held roots, three private channels. */
|
||||
/**
|
||||
* A heavy membership: staff secrets, two held roots and a staff member's worth of private
|
||||
* channels. Each join re-reads and re-packs the whole List, so the cost of building one grows
|
||||
* with (joins x List size); fat entries span several fragments in a few dozen joins instead of
|
||||
* hundreds, which keeps the suite inside `runTest`'s timeout on slow targets (the iOS
|
||||
* simulator ran 250 lean joins past it).
|
||||
*/
|
||||
private fun entry(n: Int) =
|
||||
ConcordCommunityListEntry(
|
||||
id = hex(n, 1),
|
||||
@@ -73,7 +79,7 @@ class ConcordChannelListFragmentationTest {
|
||||
controlPk = hex(n, 5),
|
||||
controlRoot = hex(n, 6),
|
||||
heldRoots = listOf(HeldRoot(0, hex(n, 7)), HeldRoot(1, hex(n, 8), hex(n, 9))),
|
||||
privateChannels = (0 until 3).map { PrivateChannelKey(hex(n, 20 + it), hex(n, 30 + it), 1, "private-$it") },
|
||||
privateChannels = (0 until PRIVATE_CHANNELS).map { PrivateChannelKey(hex(n, 100 + it), hex(n, 200 + it), 1, "private-$it") },
|
||||
relays = listOf("wss://nos.lol/", "wss://nostr.mom/"),
|
||||
name = "Community number $n",
|
||||
addedAt = 1_000L + n,
|
||||
@@ -140,28 +146,28 @@ class ConcordChannelListFragmentationTest {
|
||||
fun aLargeListSplitsIntoFragmentsThatEachFitAnEvent() =
|
||||
runTest {
|
||||
val (list, repo) = device()
|
||||
val published = joinAll(list, 250)
|
||||
val published = joinAll(list, COUNT)
|
||||
|
||||
val wire = repo.fragments
|
||||
assertTrue(wire.size >= 2, "250 heavy memberships must not fit one fragment; got ${wire.size}")
|
||||
assertTrue(wire.size >= 2, "$COUNT heavy memberships must not fit one fragment; got ${wire.size}")
|
||||
for (fragment in published) {
|
||||
val bytes = fragment.toJson().encodeToByteArray().size
|
||||
assertTrue(bytes <= ConcordListFragments.EVENT_CEILING_BYTES, "fragment ${fragment.index()} is $bytes bytes, over the ${ConcordListFragments.EVENT_CEILING_BYTES} ceiling")
|
||||
}
|
||||
assertEquals(250, list.entries().size)
|
||||
assertEquals(COUNT, list.entries().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anotherDeviceReadsEveryMembershipFromThePublishedFragments() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
joinAll(first, COUNT)
|
||||
|
||||
val (second, _) = device(repo.fragments)
|
||||
val read = second.entries().associateBy { it.id }
|
||||
|
||||
assertEquals(250, read.size)
|
||||
for (n in listOf(0, 1, 124, 248, 249)) {
|
||||
assertEquals(COUNT, read.size)
|
||||
for (n in listOf(0, 1, COUNT / 2, COUNT - 2, COUNT - 1)) {
|
||||
val want = entry(n)
|
||||
val got = read.getValue(want.id)
|
||||
assertEquals(want.root, got.root)
|
||||
@@ -176,27 +182,27 @@ class ConcordChannelListFragmentationTest {
|
||||
fun editsOnAnotherDeviceSurviveTheRoundTripAcrossFragments() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
joinAll(first, COUNT)
|
||||
|
||||
// The second device leaves a membership that lives in a later fragment and joins a new one.
|
||||
val (second, secondRepo) = device(repo.fragments)
|
||||
second.unfollow(entry(240).id)
|
||||
second.unfollow(entry(COUNT - 3).id)
|
||||
second.follow(entry(900))
|
||||
|
||||
// The first device, fed what the second published, converges on the same List.
|
||||
val (third, _) = device(secondRepo.fragments)
|
||||
val ids = third.entries().map { it.id }.toSet()
|
||||
assertEquals(250, ids.size)
|
||||
assertTrue(entry(240).id !in ids, "the leave must hold across fragments")
|
||||
assertEquals(COUNT, ids.size)
|
||||
assertTrue(entry(COUNT - 3).id !in ids, "the leave must hold across fragments")
|
||||
assertTrue(entry(900).id in ids)
|
||||
assertTrue(entry(0).id in ids && entry(249).id in ids)
|
||||
assertTrue(entry(0).id in ids && entry(COUNT - 1).id in ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeviceMissingFragmentsJoinsIntoAHeldFragmentWithRoom() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
joinAll(first, COUNT)
|
||||
val wire = repo.fragments.sortedBy { it.index() }
|
||||
assertTrue(wire.size >= 3)
|
||||
|
||||
@@ -210,7 +216,7 @@ class ConcordChannelListFragmentationTest {
|
||||
val merged = (partialRepo.fragments + wire).groupBy { it.index() }.map { (_, copies) -> copies.maxBy { it.createdAt } }
|
||||
val (reader, _) = device(merged)
|
||||
val ids = reader.entries().map { it.id }.toSet()
|
||||
assertEquals(251, ids.size, "every membership from every fragment plus the new join")
|
||||
assertEquals(COUNT + 1, ids.size, "every membership from every fragment plus the new join")
|
||||
assertTrue(entry(901).id in ids)
|
||||
}
|
||||
|
||||
@@ -218,7 +224,7 @@ class ConcordChannelListFragmentationTest {
|
||||
fun aDeviceHoldingOnlyFullFragmentsRefusesTheJoinRatherThanOverflowing() =
|
||||
runTest {
|
||||
val (first, repo) = device()
|
||||
joinAll(first, 250)
|
||||
joinAll(first, COUNT)
|
||||
val fragmentZero = repo.fragments.first { it.index() == 0 }
|
||||
|
||||
// Opening a new fragment is a repack, which needs the complete List (CORD-02 §8); an
|
||||
@@ -228,4 +234,12 @@ class ConcordChannelListFragmentationTest {
|
||||
assertFailsWith<ConcordListTooLargeException> { partial.follow(entry(902)) }
|
||||
assertEquals(listOf(fragmentZero), partialRepo.fragments, "a refused write publishes nothing")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Private channels per membership: ~6 KB entries, a handful per fragment. */
|
||||
private const val PRIVATE_CHANNELS = 30
|
||||
|
||||
/** Joins per test: enough for several fragments with the last one only partly filled. */
|
||||
private const val COUNT = 30
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user