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:
Claude
2026-09-29 22:24:21 +00:00
2 changed files with 106 additions and 44 deletions
@@ -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,
@@ -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
}
}