From e82e8148a7b6e777c82cfd5d7791e5b7d3863e28 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:25:23 -0400 Subject: [PATCH 1/6] fix(chat-ui): close a photo into its bubble's corners and under the top bar The flight back from the viewer ended on one radius for every corner, then snapped to the bubble's grouped corners once it settled. It now animates to the corners the bubble draws (first/middle/last, caption, quote). The flying photo was drawn over the transcript's top bar and bottom composer and only went behind them when the row took over. It is now clipped to the transcript area on the way back, easing out to the full screen only near fully open. --- .../internal/screens/MessengerScreen.kt | 6 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 28 +++++---- .../shared/chat/ui/media/ChatMediaViewer.kt | 17 +++++- .../shared/chat/ui/media/ChatPhotoOverlay.kt | 16 ++++- .../shared/chat/ui/media/ChatPhotoSources.kt | 41 +++++++++++-- .../chat/ui/media/ChatPhotoTransition.kt | 53 +++++++++++++--- .../chat/ui/media/MediaMessageBubble.kt | 6 +- .../chat/ui/media/ChatPhotoTransitionTest.kt | 60 +++++++++++++++---- 8 files changed, 185 insertions(+), 42 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 86ee87c93..3999a052c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import com.flipcash.shared.chat.ui.media.ChatPhotoOverlay import com.flipcash.shared.chat.ui.media.ChatPhotoSources +import com.flipcash.shared.chat.ui.media.chatPhotoTranscript import com.flipcash.shared.chat.ui.media.LocalChatMediaProgress import com.flipcash.shared.chat.ui.media.LocalChatPhotoSources import androidx.compose.runtime.LaunchedEffect @@ -412,6 +413,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { .fillMaxSize() .testTag("chat_message_list") .softTopEdge(ChatTopEdge.blurHold(barHeight)) + .chatPhotoTranscript( + sources = photoSources, + top = barHeight, + bottom = overlapPadding.calculateBottomPadding(), + ) .hazeSource(hazeState), state = state, contentPadding = overlapPadding, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index c5b037316..09e0dd09a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -972,12 +972,25 @@ internal fun Bubble( @Composable fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerShape { + val cornerSpec = spring(dampingRatio = 0.68f, stiffness = 500f) + + val targets = bubbleCorners(position, isFromSelf) + + val topStart by animateDpAsState(targets.topStart, cornerSpec, label = "cTS") + val topEnd by animateDpAsState(targets.topEnd, cornerSpec, label = "cTE") + val bottomEnd by animateDpAsState(targets.bottomEnd, cornerSpec, label = "cBE") + val bottomStart by animateDpAsState(targets.bottomStart, cornerSpec, label = "cBS") + + return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) +} + +/** The corners [bubbleShape] settles on for a bubble at [position]. */ +@Composable +internal fun bubbleCorners(position: BubblePosition, isFromSelf: Boolean): BubbleCorners { val l = BubbleDefaults.cornerLarge val s = BubbleDefaults.cornerSmall - val cornerSpec = spring(dampingRatio = 0.68f, stiffness = 500f) - - val targets = when (position) { + return when (position) { BubblePosition.Solo -> BubbleCorners(l, l, l, l) BubblePosition.First -> if (isFromSelf) BubbleCorners(l, l, s, l) else BubbleCorners( l, @@ -1000,16 +1013,9 @@ fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerSha l ) } - - val topStart by animateDpAsState(targets.topStart, cornerSpec, label = "cTS") - val topEnd by animateDpAsState(targets.topEnd, cornerSpec, label = "cTE") - val bottomEnd by animateDpAsState(targets.bottomEnd, cornerSpec, label = "cBE") - val bottomStart by animateDpAsState(targets.bottomStart, cornerSpec, label = "cBS") - - return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) } -private data class BubbleCorners( +internal data class BubbleCorners( val topStart: Dp, val topEnd: Dp, val bottomEnd: Dp, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 203878a05..31a70ecef 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -33,6 +33,8 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.layout @@ -225,6 +227,16 @@ fun ChatMediaViewer( ) { Box( modifier = Modifier + .drawWithContent { + // In the container's coordinates, before the photo is placed in it: keeps a + // photo landing on a bubble under the chat's bars behind them, as the bubble is. + val clip = frame?.invoke()?.clip + if (clip == null) { + drawContent() + } else { + clipRect(clip.left, clip.top, clip.right, clip.bottom) { this@drawWithContent.drawContent() } + } + } .layout { measurable, constraints -> // The photo's own frame inside the container, which a host animates; the // whole container when it does not. @@ -246,8 +258,9 @@ fun ChatMediaViewer( if (frame != null) { val f = frame() alpha = f.contentAlpha - shape = RoundedCornerShape(f.radiusPx) - clip = f.radiusPx > 0f + val c = f.corners + shape = RoundedCornerShape(c.topStart, c.topEnd, c.bottomEnd, c.bottomStart) + clip = !c.isSquare } }, ) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt index f7793deb8..f4c2bfcfb 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt @@ -48,7 +48,8 @@ private const val STIFFNESS = 439f internal class ChatPhotoPresentation( private val scope: CoroutineScope, private val lookup: () -> Rect?, - private val sourceRadiusPx: Float, + private val sourceCorners: ChatPhotoCorners, + private val transcript: () -> Rect?, private val content: () -> Size, private val density: Float, private val reduceMotion: Boolean, @@ -95,7 +96,15 @@ internal class ChatPhotoPresentation( t = ChatPhotoTransition.dragProgress(offset, size.height) anchor = ChatPhotoTransition.draggedRect(open, offset, t) } - return ChatPhotoTransition.frame(source, sourceRadiusPx, anchor, t, progress.value) + return ChatPhotoTransition.frame( + source = source, + sourceCorners = sourceCorners, + anchor = anchor, + anchorPull = t, + progress = progress.value, + transcript = transcript()?.translate(-origin), + container = size, + ) } suspend fun open() { @@ -168,7 +177,8 @@ fun ChatPhotoOverlay( ChatPhotoPresentation( scope = scope, lookup = { source.boundsInRoot() }, - sourceRadiusPx = source.radiusPx, + sourceCorners = source.corners, + transcript = { sources.transcript }, content = { source.imageSize ?: zoomState.content.takeIf { it.width > 0f && it.height > 0f } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt index dbd25b7e5..3adb8e4ff 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt @@ -17,7 +17,9 @@ import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import com.flipcash.shared.chat.media.ChatPhoto -import com.flipcash.shared.chat.ui.BubbleDefaults +import androidx.compose.ui.unit.Dp +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.bubbleCorners /** What the overlay needs of one on-screen photo bubble: where it is, and what it draws. */ @Stable @@ -25,7 +27,7 @@ class ChatPhotoSource internal constructor( internal val model: ChatPhoto, /** The photo's own size, for the shape it opens to. Null when the message did not say. */ internal val imageSize: Size?, - internal val radiusPx: Float, + internal val corners: ChatPhotoCorners, ) { internal var coordinates: LayoutCoordinates? = null @@ -42,6 +44,12 @@ class ChatPhotoSource internal constructor( class ChatPhotoSources { private val sources = HashMap() + /** + * The part of the chat its top and bottom bars do not cover, in the root's coordinates. A + * bubble under a bar is drawn behind it, so the photo landing there is clipped to this. + */ + internal var transcript: Rect? = null + /** The message whose bubble is covered by the overlay and drawn at zero alpha. */ var hiddenId by mutableStateOf(null) @@ -69,17 +77,17 @@ internal fun Modifier.chatPhotoSource( model: ChatPhoto?, imageWidth: Int?, imageHeight: Int?, - radiusPx: Float, + corners: ChatPhotoCorners, ): Modifier { val sources = LocalChatPhotoSources.current if (sources == null || messageId == null || model == null) return this - val source = remember(model, imageWidth, imageHeight, radiusPx) { + val source = remember(model, imageWidth, imageHeight, corners) { val size = if ((imageWidth ?: 0) > 0 && (imageHeight ?: 0) > 0) { Size(imageWidth!!.toFloat(), imageHeight!!.toFloat()) } else { null } - ChatPhotoSource(model, size, radiusPx) + ChatPhotoSource(model, size, corners) } DisposableEffect(sources, messageId, source) { sources.register(messageId, source) @@ -90,5 +98,26 @@ internal fun Modifier.chatPhotoSource( .graphicsLayer { alpha = if (sources.hiddenId == messageId) 0f else 1f } } +/** The corners a photo bubble at [position] settles on, in px. */ +@Composable +internal fun bubblePhotoCorners(position: BubblePosition, isFromSelf: Boolean): ChatPhotoCorners { + val c = bubbleCorners(position, isFromSelf) + return with(LocalDensity.current) { + ChatPhotoCorners(c.topStart.toPx(), c.topEnd.toPx(), c.bottomEnd.toPx(), c.bottomStart.toPx()) + } +} + +/** + * Reports the part of this chat its bars do not cover to [LocalChatPhotoSources]: these bounds + * less [top] and [bottom]. + */ @Composable -internal fun bubblePhotoRadiusPx(): Float = with(LocalDensity.current) { BubbleDefaults.cornerLarge.toPx() } +fun Modifier.chatPhotoTranscript(sources: ChatPhotoSources, top: Dp, bottom: Dp): Modifier { + val density = LocalDensity.current + return onGloballyPositioned { + val bounds = it.boundsInRoot() + with(density) { + sources.transcript = Rect(bounds.left, bounds.top + top.toPx(), bounds.right, bounds.bottom - bottom.toPx()) + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt index a4472fbbd..4c61d47fe 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt @@ -10,26 +10,59 @@ import androidx.compose.ui.util.lerp * Where the photo is drawn at one instant of the overlay's open, drag and close, in the overlay's * own coordinates. * - * @property radiusPx corner radius of [rect]. + * @property corners corner radii of [rect]. * @property contentAlpha opacity of the photo; below 1 only when there is no bubble to fly to or from. * @property backdrop opacity of the black behind it. * @property chromeAlpha opacity of the close and share buttons. */ data class ChatMediaFrame( val rect: Rect, - val radiusPx: Float, + val corners: ChatPhotoCorners, val contentAlpha: Float, val backdrop: Float, val chromeAlpha: Float, + val clip: Rect? = null, ) { companion object { /** Nothing drawn: the overlay has no size yet. */ - val Hidden = ChatMediaFrame(Rect.Zero, 0f, 0f, 0f, 0f) + val Hidden = ChatMediaFrame(Rect.Zero, ChatPhotoCorners.uniform(0f), 0f, 0f, 0f) } } +/** A photo's corner radii in px, start and end following the layout direction like [androidx.compose.foundation.shape.RoundedCornerShape]. */ +data class ChatPhotoCorners( + val topStart: Float, + val topEnd: Float, + val bottomEnd: Float, + val bottomStart: Float, +) { + val max: Float get() = maxOf(maxOf(topStart, topEnd), maxOf(bottomEnd, bottomStart)) + + val isSquare: Boolean get() = max <= 0f + + companion object { + fun uniform(radius: Float) = ChatPhotoCorners(radius, radius, radius, radius) + } +} + +internal fun lerp(start: ChatPhotoCorners, stop: ChatPhotoCorners, fraction: Float) = ChatPhotoCorners( + topStart = lerp(start.topStart, stop.topStart, fraction), + topEnd = lerp(start.topEnd, stop.topEnd, fraction), + bottomEnd = lerp(start.bottomEnd, stop.bottomEnd, fraction), + bottomStart = lerp(start.bottomStart, stop.bottomStart, fraction), +) + /** The pure parts of the photo overlay: the pull's geometry, the release decision, the rect blend. */ internal object ChatPhotoTransition { + /** + * The last stretch of the open over which the clip widens from the transcript to the screen. + * Spread over the whole open, the clip trails a photo that crosses the bars early in its + * flight, and the photo is drawn over them until it nearly lands. + */ + private const val CLIP_RELEASE = 0.2f + + private fun clipProgress(p: Float) = (1f - (1f - p) / CLIP_RELEASE).coerceIn(0f, 1f) + /** A pull of this fraction of the container's height takes the shrink and the fade to their ends. */ const val DRAG_FULL_FRACTION = 0.5f @@ -94,31 +127,37 @@ internal object ChatPhotoTransition { * mid-pull rect. One progress drives both directions, so the target ([source] or [anchor]) may * move while it runs. With no [source] the photo grows from, and shrinks to, its own center while fading. * + * @param sourceCorners the bubble's own corners, which can differ where it joins a run. * @param anchorPull how far into a pull [anchor] is; the corners round and the backdrop and buttons fade with it. + * @param transcript where the chat is not covered by its bars. The photo is clipped to it on + * the bubble, the way the bubble is, and to the whole [container] when open. Null clips nothing. */ fun frame( source: Rect?, - sourceRadius: Float, + sourceCorners: ChatPhotoCorners, anchor: Rect, anchorPull: Float, progress: Float, + transcript: Rect? = null, + container: Size = Size.Zero, ): ChatMediaFrame { val p = progress.coerceIn(0f, 1f) - val anchorRadius = if (source != null) sourceRadius * anchorPull else 0f + val anchorCorners = ChatPhotoCorners.uniform(if (source != null) sourceCorners.max * anchorPull else 0f) val backdrop = dragBackdrop(anchorPull) * p val chrome = (1f - anchorPull) * p return if (source != null) { ChatMediaFrame( rect = lerp(source, anchor, p), - radiusPx = lerp(sourceRadius, anchorRadius, p), + corners = lerp(sourceCorners, anchorCorners, p), contentAlpha = 1f, backdrop = backdrop, chromeAlpha = chrome, + clip = transcript?.let { lerp(it, Rect(Offset.Zero, container), clipProgress(p)) }, ) } else { ChatMediaFrame( rect = lerp(scaledAbout(anchor, DETACHED_SCALE), anchor, p), - radiusPx = anchorRadius, + corners = anchorCorners, contentAlpha = p, backdrop = backdrop, chromeAlpha = chrome, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt index d89184c68..8bc2a5c06 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt @@ -181,7 +181,11 @@ internal fun MediaMessageBubble( model = model, imageWidth = image?.width, imageHeight = image?.height, - radiusPx = bubblePhotoRadiusPx(), + corners = bubblePhotoCorners( + position = (if (quote != null) position.withPhotoAbove() else position) + .let { if (body.caption?.text.isNullOrEmpty()) it else it.withCaptionBelow() }, + isFromSelf = item.isFromSelf, + ), ), onClick = onClick, onLongClick = onLongClick, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt index 4d075d3db..73ab7f331 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt @@ -15,48 +15,84 @@ class ChatPhotoTransitionTest { @Test fun `progress zero sits on the bubble and one on the anchor`() { - val start = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 0f) + val start = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 0f) assertEquals(bubble, start.rect) - assertEquals(48f, start.radiusPx) + assertEquals(ChatPhotoCorners.uniform(48f), start.corners) assertEquals(0f, start.backdrop) - val end = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 1f) + val end = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 1f) assertEquals(open, end.rect) - assertEquals(0f, end.radiusPx) + assertEquals(ChatPhotoCorners.uniform(0f), end.corners) assertEquals(1f, end.backdrop) assertEquals(1f, end.chromeAlpha) } @Test fun `halfway is the midpoint of both rects and corners`() { - val mid = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 0f, progress = 0.5f) + val mid = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 0.5f) assertEquals(Rect(300f, 500f, 1150f, 1275f), mid.rect) - assertEquals(24f, mid.radiusPx) + assertEquals(ChatPhotoCorners.uniform(24f), mid.corners) assertEquals(0.5f, mid.backdrop) } @Test fun `a pulled anchor keeps its corners and dims the backdrop`() { - val pulled = ChatPhotoTransition.frame(bubble, 48f, open, anchorPull = 1f, progress = 1f) - assertEquals(48f, pulled.radiusPx) + val pulled = ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(48f), open, anchorPull = 1f, progress = 1f) + assertEquals(ChatPhotoCorners.uniform(48f), pulled.corners) assertEquals(ChatPhotoTransition.MIN_DRAG_BACKDROP, pulled.backdrop) assertEquals(0f, pulled.chromeAlpha) } @Test fun `without a bubble the photo fades and shrinks about its own center`() { - val gone = ChatPhotoTransition.frame(null, 48f, open, anchorPull = 0f, progress = 0f) + val gone = ChatPhotoTransition.frame(null, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 0f) assertEquals(0f, gone.contentAlpha) assertEquals(open.center, gone.rect.center) assertEquals(open.width * ChatPhotoTransition.DETACHED_SCALE, gone.rect.width) - val shown = ChatPhotoTransition.frame(null, 48f, open, anchorPull = 0f, progress = 1f) + val shown = ChatPhotoTransition.frame(null, ChatPhotoCorners.uniform(48f), open, anchorPull = 0f, progress = 1f) assertEquals(1f, shown.contentAlpha) assertEquals(open, shown.rect) } + @Test + fun `each corner starts on the bubble's own corner and opens square`() { + val tail = ChatPhotoCorners(topStart = 48f, topEnd = 48f, bottomEnd = 12f, bottomStart = 48f) + val start = ChatPhotoTransition.frame(bubble, tail, open, anchorPull = 0f, progress = 0f) + assertEquals(tail, start.corners) + val mid = ChatPhotoTransition.frame(bubble, tail, open, anchorPull = 0f, progress = 0.5f) + assertEquals(ChatPhotoCorners(24f, 24f, 6f, 24f), mid.corners) + val end = ChatPhotoTransition.frame(bubble, tail, open, anchorPull = 0f, progress = 1f) + assertEquals(ChatPhotoCorners.uniform(0f), end.corners) + } + + @Test + fun `the photo is clipped to the transcript at the bubble and to the screen when open`() { + val transcript = Rect(0f, 300f, 1200f, 2100f) + val screen = Rect(Offset.Zero, container) + val corners = ChatPhotoCorners.uniform(48f) + val start = ChatPhotoTransition.frame(bubble, corners, open, 0f, 0f, transcript, container) + assertEquals(transcript, start.clip) + val end = ChatPhotoTransition.frame(bubble, corners, open, 0f, 1f, transcript, container) + assertEquals(screen, end.clip) + // In flight the photo already passes under the bars, so it is behind them well before it lands. + val mid = ChatPhotoTransition.frame(bubble, corners, open, 0f, 0.5f, transcript, container) + assertEquals(transcript, mid.clip) + val leaving = ChatPhotoTransition.frame(bubble, corners, open, 0f, 0.9f, transcript, container) + assertEquals(150f, leaving.clip!!.top, absoluteTolerance = 0.5f) + assertEquals(2250f, leaving.clip!!.bottom, absoluteTolerance = 0.5f) + } + + @Test + fun `without a transcript or a bubble nothing is clipped`() { + val corners = ChatPhotoCorners.uniform(48f) + assertEquals(null, ChatPhotoTransition.frame(bubble, corners, open, 0f, 0f).clip) + val transcript = Rect(0f, 300f, 1200f, 2100f) + assertEquals(null, ChatPhotoTransition.frame(null, corners, open, 0f, 0f, transcript, container).clip) + } + @Test fun `progress outside zero to one is clamped`() { - assertEquals(bubble, ChatPhotoTransition.frame(bubble, 0f, open, 0f, -0.2f).rect) - assertEquals(open, ChatPhotoTransition.frame(bubble, 0f, open, 0f, 1.3f).rect) + assertEquals(bubble, ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(0f), open, 0f, -0.2f).rect) + assertEquals(open, ChatPhotoTransition.frame(bubble, ChatPhotoCorners.uniform(0f), open, 0f, 1.3f).rect) } @Test From 123d561891ebca05b30f506a5ce11a580df2df07 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:25:24 -0400 Subject: [PATCH 2/6] fix(chat-ui): let a double tap through on a bubble with no tap or long press Bubble only attached its gesture handler when a tap or long press was set, so a bubble that only reacts to a double tap never got one. --- .../flipcash/shared/chat/ui/MessageBubble.kt | 2 +- .../chat/ui/media/MediaMessageBubbleTest.kt | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 09e0dd09a..050b829bd 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -948,7 +948,7 @@ internal fun Bubble( drawRect(Color.White, alpha = ATTENTION_SCRIM_ALPHA * strength) } } - .addIf(onClick != null || onLongClick != null) { + .addIf(onClick != null || onLongClick != null || onDoubleClick != null) { // combinedClickable rather than two modifiers: a bubble that takes the tap takes // the long press with it, so both gestures are reported from the same target. // A double tap here waits out the timeout before a single tap acts: that delay is diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt index ec5056c39..8bc7ab8c3 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt @@ -4,6 +4,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.performTouchInput import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.BlobMetadata @@ -76,6 +78,45 @@ class MediaMessageBubbleTest { composeTestRule.onNodeWithText("sunset").assertIsDisplayed() } + @Test + fun `double tapping a photo presents the reaction strip`() { + var doubleTaps = 0 + composeTestRule.setContent { + DesignSystem { + ContentBubble( + item = item(previewing = false), + position = BubblePosition.Solo, + onLongClick = {}, + onDoubleClick = { doubleTaps++ }, + ) + } + } + + composeTestRule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(1, doubleTaps) + } + + @Test + fun `double tapping a photo with nothing else to do still presents the reaction strip`() { + var doubleTaps = 0 + composeTestRule.setContent { + DesignSystem { + ContentBubble( + item = item(previewing = false), + position = BubblePosition.Solo, + onDoubleClick = { doubleTaps++ }, + ) + } + } + + composeTestRule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).performTouchInput { doubleClick() } + composeTestRule.waitForIdle() + + assertEquals(1, doubleTaps) + } + @Test fun `a photo only opens when it is showing, delivered and loaded`() { assertTrue(canOpenPhoto(blurhashOnly = false, failed = false, unavailable = false)) From ba6596bd8ff91b76a63678938afdf09920118aa0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:40:35 -0400 Subject: [PATCH 3/6] fix(chat-ui): land a closing photo behind the chat's top bar The overlay clipped the photo hard at the bar's bottom edge, while the bubble it lands on sits under the bar's blur and fade, so the part under the bar appeared in one frame when the overlay went. Over the last fifth of the close the transcript now draws the photo itself, inside the blur and haze, and the overlay's copy fades out as it fades in. --- .../internal/screens/MessengerScreen.kt | 6 +- .../shared/chat/ui/media/ChatMediaViewer.kt | 7 +- .../shared/chat/ui/media/ChatPhotoOverlay.kt | 8 ++ .../shared/chat/ui/media/ChatPhotoSources.kt | 80 +++++++++++++++++-- .../chat/ui/media/ChatPhotoTransition.kt | 15 ++++ .../chat/ui/media/ChatPhotoTransitionTest.kt | 20 +++++ 6 files changed, 128 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 3999a052c..f1da2fa4f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -413,12 +413,14 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { .fillMaxSize() .testTag("chat_message_list") .softTopEdge(ChatTopEdge.blurHold(barHeight)) + .hazeSource(hazeState) + // Inside the edge blur and the haze, so a photo landing under the bars + // passes behind them the way its bubble does. .chatPhotoTranscript( sources = photoSources, top = barHeight, bottom = overlapPadding.calculateBottomPadding(), - ) - .hazeSource(hazeState), + ), state = state, contentPadding = overlapPadding, messages = messages, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 31a70ecef..508638ec7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -95,6 +96,7 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * @param frame the photo's rect, corners and the opacities, read at layout and draw time so a * host can animate them without recomposing. Null fills the container. * @param interactive false while the host is closing the viewer: touches and buttons do nothing. + * @param onPainter receives the painter the photo is drawn with, for a host that draws a copy of it. */ @Composable fun ChatMediaViewer( @@ -111,6 +113,7 @@ fun ChatMediaViewer( onPull: ((Offset) -> Unit)? = null, onPullEnd: ((Offset) -> Unit)? = null, interactive: Boolean = true, + onPainter: ((Painter) -> Unit)? = null, ) { val scope = rememberCoroutineScope() val dragY = remember { Animatable(0f) } @@ -130,6 +133,7 @@ fun ChatMediaViewer( } }, ) + if (onPainter != null) SideEffect { onPainter(painter) } Box( modifier = modifier @@ -257,7 +261,8 @@ fun ChatMediaViewer( translationY = zoomState.offset.y + if (frame == null) dragY.value else 0f if (frame != null) { val f = frame() - alpha = f.contentAlpha + // The transcript draws the rest, under the chat's bars. + alpha = f.contentAlpha * (1f - f.handoff) val c = f.corners shape = RoundedCornerShape(c.topStart, c.topEnd, c.bottomEnd, c.bottomStart) clip = !c.isSquare diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt index f4c2bfcfb..3493abc83 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size @@ -196,6 +197,12 @@ fun ChatPhotoOverlay( sources.hiddenId = messageId onDispose { if (sources.hiddenId == messageId) sources.hiddenId = null } } + var painter by remember(messageId) { mutableStateOf(null) } + DisposableEffect(sources, presentation, painter) { + val landing = painter?.let { ChatPhotoLanding(it, presentation::frame) { presentation.origin } } + sources.landing = landing + onDispose { if (sources.landing === landing) sources.landing = null } + } LaunchedEffect(presentation) { presentation.open() } BackHandler { presentation.dismiss() } @@ -227,6 +234,7 @@ fun ChatPhotoOverlay( onPull = presentation::onPull, onPullEnd = presentation::onPullEnd, interactive = !presentation.closing, + onPainter = { painter = it }, ) } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt index 3adb8e4ff..de483f841 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt @@ -9,9 +9,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned @@ -53,6 +64,9 @@ class ChatPhotoSources { /** The message whose bubble is covered by the overlay and drawn at zero alpha. */ var hiddenId by mutableStateOf(null) + /** The open photo, for the transcript to draw its share of near the bubble. */ + internal var landing by mutableStateOf(null) + internal fun register(id: Long, source: ChatPhotoSource) { sources[id] = source } @@ -107,17 +121,73 @@ internal fun bubblePhotoCorners(position: BubblePosition, isFromSelf: Boolean): } } +/** + * The photo the overlay is flying: what it draws, and where, in the overlay's coordinates at + * [origin] in the root. + */ +internal class ChatPhotoLanding( + val painter: Painter, + val frame: () -> ChatMediaFrame, + val origin: () -> Offset, +) + /** * Reports the part of this chat its bars do not cover to [LocalChatPhotoSources]: these bounds - * less [top] and [bottom]. + * less [top] and [bottom]. Also draws the open photo's [ChatMediaFrame.handoff] share over the + * transcript, so apply it inside any blur or haze the bars put on the transcript: the photo then + * passes under them the way its bubble does. */ @Composable fun Modifier.chatPhotoTranscript(sources: ChatPhotoSources, top: Dp, bottom: Dp): Modifier { val density = LocalDensity.current - return onGloballyPositioned { - val bounds = it.boundsInRoot() - with(density) { - sources.transcript = Rect(bounds.left, bounds.top + top.toPx(), bounds.right, bounds.bottom - bottom.toPx()) + var origin = Offset.Zero + return this + .onGloballyPositioned { + val bounds = it.boundsInRoot() + origin = bounds.topLeft + with(density) { + sources.transcript = Rect(bounds.left, bounds.top + top.toPx(), bounds.right, bounds.bottom - bottom.toPx()) + } + } + .drawWithContent { + drawContent() + val landing = sources.landing ?: return@drawWithContent + val frame = landing.frame() + if (frame.handoff <= 0f) return@drawWithContent + translate(landing.origin().x - origin.x, landing.origin().y - origin.y) { + drawChatPhoto(landing.painter, frame.rect, frame.corners, frame.contentAlpha * frame.handoff) + } + } +} + +/** + * [painter] cropped to fill [rect] with [corners], as the overlay's `Image` draws it with + * [ContentScale.Crop], so the two copies line up while one fades into the other. + */ +internal fun DrawScope.drawChatPhoto(painter: Painter, rect: Rect, corners: ChatPhotoCorners, alpha: Float) { + if (rect.width <= 0f || rect.height <= 0f || alpha <= 0f) return + val ltr = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Ltr + val path = Path().apply { + addRoundRect( + RoundRect( + rect = rect, + topLeft = CornerRadius(if (ltr) corners.topStart else corners.topEnd), + topRight = CornerRadius(if (ltr) corners.topEnd else corners.topStart), + bottomRight = CornerRadius(if (ltr) corners.bottomEnd else corners.bottomStart), + bottomLeft = CornerRadius(if (ltr) corners.bottomStart else corners.bottomEnd), + ), + ) + } + val src = painter.intrinsicSize + val drawn = if (src.isUnspecified || src.width <= 0f || src.height <= 0f) { + rect.size + } else { + val scale = ContentScale.Crop.computeScaleFactor(src, rect.size) + Size(src.width * scale.scaleX, src.height * scale.scaleY) + } + clipPath(path) { + translate(rect.left + (rect.width - drawn.width) / 2f, rect.top + (rect.height - drawn.height) / 2f) { + with(painter) { draw(drawn, alpha) } } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt index 4c61d47fe..8d23fe05c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransition.kt @@ -14,6 +14,10 @@ import androidx.compose.ui.util.lerp * @property contentAlpha opacity of the photo; below 1 only when there is no bubble to fly to or from. * @property backdrop opacity of the black behind it. * @property chromeAlpha opacity of the close and share buttons. + * @property clip where the overlay may draw the photo; null for anywhere. + * @property handoff how much of the photo the transcript draws instead of the overlay, from 0 to 1. + * The transcript's copy sits under the bars' blur and fade the way the bubble does, so the photo + * lands looking like the bubble rather than changing as the overlay goes. */ data class ChatMediaFrame( val rect: Rect, @@ -22,6 +26,7 @@ data class ChatMediaFrame( val backdrop: Float, val chromeAlpha: Float, val clip: Rect? = null, + val handoff: Float = 0f, ) { companion object { /** Nothing drawn: the overlay has no size yet. */ @@ -63,6 +68,15 @@ internal object ChatPhotoTransition { private fun clipProgress(p: Float) = (1f - (1f - p) / CLIP_RELEASE).coerceIn(0f, 1f) + /** + * The first stretch of the open over which the transcript hands the photo to the overlay, and + * the last of the close over which it takes it back. Short, since the transcript's copy is + * under the backdrop, which is near clear only here. + */ + private const val HANDOFF = 0.2f + + private fun handoff(p: Float) = (1f - p / HANDOFF).coerceIn(0f, 1f) + /** A pull of this fraction of the container's height takes the shrink and the fade to their ends. */ const val DRAG_FULL_FRACTION = 0.5f @@ -153,6 +167,7 @@ internal object ChatPhotoTransition { backdrop = backdrop, chromeAlpha = chrome, clip = transcript?.let { lerp(it, Rect(Offset.Zero, container), clipProgress(p)) }, + handoff = if (transcript != null) handoff(p) else 0f, ) } else { ChatMediaFrame( diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt index 73ab7f331..0070f1037 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoTransitionTest.kt @@ -81,6 +81,26 @@ class ChatPhotoTransitionTest { assertEquals(2250f, leaving.clip!!.bottom, absoluteTolerance = 0.5f) } + @Test + fun `the transcript takes the photo over in the last stretch before the bubble`() { + val transcript = Rect(0f, 300f, 1200f, 2100f) + val corners = ChatPhotoCorners.uniform(48f) + fun handoff(p: Float) = ChatPhotoTransition.frame(bubble, corners, open, 0f, p, transcript, container).handoff + assertEquals(1f, handoff(0f)) + assertEquals(0.5f, handoff(0.1f), absoluteTolerance = 0.001f) + assertEquals(0f, handoff(0.2f)) + assertEquals(0f, handoff(0.6f)) + assertEquals(0f, handoff(1f)) + } + + @Test + fun `nothing is handed to the transcript without one or without a bubble`() { + val corners = ChatPhotoCorners.uniform(48f) + assertEquals(0f, ChatPhotoTransition.frame(bubble, corners, open, 0f, 0f).handoff) + val transcript = Rect(0f, 300f, 1200f, 2100f) + assertEquals(0f, ChatPhotoTransition.frame(null, corners, open, 0f, 0f, transcript, container).handoff) + } + @Test fun `without a transcript or a bubble nothing is clipped`() { val corners = ChatPhotoCorners.uniform(48f) From aef3b147ddda1c989a2b3c17ee1873d0d8fb9d15 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:57:02 -0400 Subject: [PATCH 4/6] fix(chat-ui): land a closing photo on its bubble's full bounds boundsInRoot() clips a layout to its ancestors, so a bubble running past the top of the message list reported only its visible part. The closing photo landed on that shorter rect, and the bubble jumped to its real size when the overlay went. Read the bubble's position and size unclipped. --- .../shared/chat/ui/media/ChatPhotoSources.kt | 15 +++++- .../ui/media/ChatPhotoSourceBoundsTest.kt | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSourceBoundsTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt index de483f841..92d708d20 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSources.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.toSize import androidx.compose.ui.platform.LocalDensity import com.flipcash.shared.chat.media.ChatPhoto import androidx.compose.ui.unit.Dp @@ -42,10 +44,19 @@ class ChatPhotoSource internal constructor( ) { internal var coordinates: LayoutCoordinates? = null - /** The bubble's bounds in the root's coordinates, or null when it is not on screen. */ - internal fun boundsInRoot(): Rect? = coordinates?.takeIf { it.isAttached }?.boundsInRoot() + /** + * The bubble's bounds in the root's coordinates, or null when it is not on screen. Unclipped: + * a bubble running under the top bar extends past the list's edge, and the photo lands on all of it. + */ + internal fun boundsInRoot(): Rect? = coordinates?.takeIf { it.isAttached }?.unclippedBoundsInRoot() } +/** + * This layout's own bounds in the root's coordinates. [boundsInRoot] clips them to every ancestor, + * so a bubble scrolled partly out of the list reports only its visible part. + */ +internal fun LayoutCoordinates.unclippedBoundsInRoot(): Rect = Rect(positionInRoot(), size.toSize()) + /** * The photo bubbles currently composed in the transcript, by message id. The overlay reads a * bubble's bounds when it opens and again when it closes, since the row may have moved in between, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSourceBoundsTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSourceBoundsTest.kt new file mode 100644 index 000000000..60d2dd9fb --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSourceBoundsTest.kt @@ -0,0 +1,47 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class ChatPhotoSourceBoundsTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun `a bubble scrolled partly past the list's top keeps its full bounds`() { + var bounds: Rect? = null + var px = 0f + rule.setContent { + px = with(LocalDensity.current) { 1.dp.toPx() } + Box(Modifier.offset(y = 100.dp).size(200.dp).clipToBounds()) { + Box( + Modifier + .offset(y = (-40).dp) + .size(width = 120.dp, height = 80.dp) + .onGloballyPositioned { bounds = it.unclippedBoundsInRoot() }, + ) + } + } + rule.waitForIdle() + + val b = bounds!! + assertEquals(60 * px, b.top, 0.5f) + assertEquals(80 * px, b.height, 0.5f) + assertEquals(120 * px, b.width, 0.5f) + } +} From caa72ff7b233d9ff900489c7adfba2f7a054bd69 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 13:10:01 -0400 Subject: [PATCH 5/6] feat(chat-ui): lay a photo reply's quote over the photo The quote sat in its own small bubble above the photo, so it read as a separate message. It is now a chip on the photo's top-left, capped at 80% of the photo's width and two lines of text, on a near-opaque ground so it stays readable over bright images. --- .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 3 + .../chat/ui/media/MediaMessageBubble.kt | 58 +++++++++++++------ .../chat/ui/media/MediaMessageBubbleTest.kt | 29 +++++++++- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index 6cd433103..b77b6fae9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -52,6 +52,8 @@ fun ChatQuotePanel( onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, + /** A solid fill under the tint, for a panel that sits on a photo rather than a filled bubble. */ + ground: Color? = null, ) { val accent = quote.accent ?: CodeTheme.colors.tertiary val name = quote.nameAccent ?: accent @@ -62,6 +64,7 @@ fun ChatQuotePanel( // fillMaxHeight child in an unbounded Row measures to zero. modifier = modifier .clip(QuotePanelDefaults.shape) + .addIf(ground != null) { Modifier.background(ground!!) } // The author's own colour at low alpha rather than a neutral scrim: the panel sits on a // filled bubble, and tinting it to match the rule is what separates the two surfaces. .background(accent.copy(alpha = QuotePanelDefaults.groundAlpha)) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt index 8bc2a5c06..be23839d3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt @@ -11,6 +11,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.unit.Dp import com.getcode.theme.CodeTheme @@ -144,21 +149,7 @@ internal fun MediaMessageBubble( verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { - if (quote != null) { - Bubble( - isFromSelf = item.isFromSelf, - position = position.withCaptionBelow(), - maxWidth = maxWidth, - onLongClick = onLongClick, - ) { - ChatQuotePanel( - quote = quote, - onClick = onQuoteClick, - onLongClick = onLongClick, - onDoubleClick = onDoubleClick, - ) - } - } + Box { ChatPhotoBubble( isFromSelf = item.isFromSelf, maxWidth = maxWidth, @@ -169,7 +160,7 @@ internal fun MediaMessageBubble( localModel = localUri, sentPreview = sentPreview, caption = body.caption?.text, - position = if (quote != null) position.withPhotoAbove() else position, + position = position, blurhashOnly = blurhashOnly, unavailable = unavailable, progress = progress?.let { p -> @@ -182,7 +173,7 @@ internal fun MediaMessageBubble( imageWidth = image?.width, imageHeight = image?.height, corners = bubblePhotoCorners( - position = (if (quote != null) position.withPhotoAbove() else position) + position = position .let { if (body.caption?.text.isNullOrEmpty()) it else it.withCaptionBelow() }, isFromSelf = item.isFromSelf, ), @@ -195,5 +186,38 @@ internal fun MediaMessageBubble( loadError = it }, ) + // The citation rides on the photo rather than in a bubble of its own above it, so the + // reply reads as one message. Matched to the photo's box, not the row, so a long quote + // wraps inside the photo instead of measuring against the row's full width. + if (quote != null) { + Box(Modifier.matchParentSize()) { + Box( + modifier = Modifier + .fillMaxWidth(PhotoQuoteDefaults.maxWidthFraction) + .padding(PhotoQuoteDefaults.inset), + ) { + ChatQuotePanel( + modifier = Modifier.testTag(PHOTO_QUOTE_TAG), + quote = quote, + // Near-opaque so the snippet reads on any photo, light or dark. + ground = CodeTheme.colors.background.copy(alpha = PhotoQuoteDefaults.groundAlpha), + onClick = onQuoteClick, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + ) + } + } + } + } } } + +/** The quote a photo reply carries, drawn over the photo's top corner. */ +internal const val PHOTO_QUOTE_TAG = "chat_photo_quote" + +private object PhotoQuoteDefaults { + /** The share of the photo's width a citation may take, so some of the photo always shows beside it. */ + const val maxWidthFraction = 0.8f + val inset = 8.dp + const val groundAlpha = 0.85f +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt index 8bc7ab8c3..2821cb3ed 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubbleTest.kt @@ -3,6 +3,7 @@ package com.flipcash.shared.chat.ui.media import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.doubleClick import androidx.compose.ui.test.performTouchInput @@ -18,6 +19,8 @@ import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.media.ChatPhotoUnavailable import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.PhotoMessageContext import com.flipcash.shared.chat.ui.BubblePosition import com.flipcash.shared.chat.ui.ContentBubble @@ -59,13 +62,14 @@ class MediaMessageBubbleTest { caption = MessageContent.Text("sunset"), ) - private fun item(previewing: Boolean) = ChatListItem.ContentBubble( + private fun item(previewing: Boolean, quote: ChatQuote? = null) = ChatListItem.ContentBubble( messageId = 7, contentIndex = 0, - content = photo, + content = if (quote != null) MessageContent.Reply(repliedMessageId = quote.messageId, content = listOf(photo)) else photo, isFromSelf = false, timestamp = Instant.fromEpochSeconds(1_000), photo = PhotoMessageContext(chatId, sealed = false, redacted = false, previewing = previewing), + quote = quote, ) @Test @@ -168,4 +172,25 @@ class MediaMessageBubbleTest { assertEquals(null, message.photoContext(chatId, previewing = false)) } + + @Test + fun `a long quoted message sits inside the photo it replies with`() { + val quote = ChatQuote( + messageId = 3, + authorName = "Someone with a fairly long display name", + snippet = ChatQuoteSnippet.Text("word ".repeat(80)), + accent = null, + nameAccent = null, + ) + composeTestRule.setContent { + DesignSystem { ContentBubble(item = item(previewing = true, quote = quote), position = BubblePosition.Solo) } + } + + val photo = composeTestRule.onNodeWithTag(PHOTO_BUBBLE_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + val chip = composeTestRule.onNodeWithTag(PHOTO_QUOTE_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() + + assertTrue(chip.left >= photo.left && chip.right <= photo.right, "chip $chip spills past photo $photo horizontally") + assertTrue(chip.top >= photo.top && chip.bottom <= photo.bottom, "chip $chip sits outside photo $photo vertically") + assertTrue(chip.right - chip.left < photo.right - photo.left, "chip $chip fills the whole photo $photo") + } } From 94ce2ca0c796a24f7670d8222c6c9e02fb4cfdc5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 13:18:44 -0400 Subject: [PATCH 6/6] fix(chat-ui): make a photo reply's quote chip concentric with the photo The chip sat 8dp in from a 12dp corner, which leaves it a 4dp corner that reads as square. It now sits 4dp in, so its corner is 8dp, and the corner nested in the photo's top-start follows that corner when grouping flattens it. --- .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 4 ++- .../chat/ui/media/MediaMessageBubble.kt | 31 +++++++++++++++++-- .../chat/ui/media/PhotoQuoteCornersTest.kt | 25 +++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteCornersTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index b77b6fae9..6571a60d4 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -54,6 +54,8 @@ fun ChatQuotePanel( onDoubleClick: (() -> Unit)? = null, /** A solid fill under the tint, for a panel that sits on a photo rather than a filled bubble. */ ground: Color? = null, + /** Overrides the panel's own corners, for a panel nested in something other than a text bubble. */ + shape: Shape? = null, ) { val accent = quote.accent ?: CodeTheme.colors.tertiary val name = quote.nameAccent ?: accent @@ -63,7 +65,7 @@ fun ChatQuotePanel( // Intrinsic minimum, so the rule can fill a height the text column decides. Without it a // fillMaxHeight child in an unbounded Row measures to zero. modifier = modifier - .clip(QuotePanelDefaults.shape) + .clip(shape ?: QuotePanelDefaults.shape) .addIf(ground != null) { Modifier.background(ground!!) } // The author's own colour at low alpha rather than a neutral scrim: the panel sits on a // filled bubble, and tinting it to match the rule is what separates the two surfaces. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt index be23839d3..c9584a94b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt @@ -11,7 +11,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.foundation.shape.RoundedCornerShape +import com.flipcash.shared.chat.ui.BubbleDefaults +import com.flipcash.shared.chat.ui.bubbleCorners import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Box @@ -19,6 +22,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.unit.Dp import com.getcode.theme.CodeTheme +import com.flipcash.shared.chat.ui.BubbleCorners import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.chat.MessageContent @@ -191,14 +195,24 @@ internal fun MediaMessageBubble( // wraps inside the photo instead of measuring against the row's full width. if (quote != null) { Box(Modifier.matchParentSize()) { + // Tighter than a text reply's surround: at the bubble's 12dp corner an 8dp gap + // leaves the chip a 4dp corner, which reads as square rather than parallel. + val inset = CodeTheme.dimens.staticGrid.x1 + val chip = photoQuoteCorners( + photoTopStart = bubbleCorners(position, item.isFromSelf).topStart, + large = BubbleDefaults.cornerLarge, + small = BubbleDefaults.cornerSmall, + inset = inset, + ) Box( modifier = Modifier .fillMaxWidth(PhotoQuoteDefaults.maxWidthFraction) - .padding(PhotoQuoteDefaults.inset), + .padding(inset), ) { ChatQuotePanel( modifier = Modifier.testTag(PHOTO_QUOTE_TAG), quote = quote, + shape = RoundedCornerShape(chip.topStart, chip.topEnd, chip.bottomEnd, chip.bottomStart), // Near-opaque so the snippet reads on any photo, light or dark. ground = CodeTheme.colors.background.copy(alpha = PhotoQuoteDefaults.groundAlpha), onClick = onQuoteClick, @@ -218,6 +232,17 @@ internal const val PHOTO_QUOTE_TAG = "chat_photo_quote" private object PhotoQuoteDefaults { /** The share of the photo's width a citation may take, so some of the photo always shows beside it. */ const val maxWidthFraction = 0.8f - val inset = 8.dp const val groundAlpha = 0.85f } + +/** + * The chip's corners, concentric with the photo it sits on: the corner nested in the photo's + * top-start follows that corner less the inset, so the gap holds round the turn whether the photo + * is rounded or flattened by its group. The other three are free-standing and take the radius a + * rounded corner would give, so a solo photo's chip is uniform. Floored at the flattened radius, + * as the text reply's panel is. + */ +internal fun photoQuoteCorners(photoTopStart: Dp, large: Dp, small: Dp, inset: Dp): BubbleCorners { + val free = max(large - inset, small) + return BubbleCorners(max(photoTopStart - inset, small), free, free, free) +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteCornersTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteCornersTest.kt new file mode 100644 index 000000000..3aed8f935 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/PhotoQuoteCornersTest.kt @@ -0,0 +1,25 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.ui.BubbleCorners +import kotlin.test.Test +import kotlin.test.assertEquals + +class PhotoQuoteCornersTest { + + @Test + fun `chip on a rounded photo corner runs parallel to it`() { + val corners = photoQuoteCorners(photoTopStart = 20.dp, large = 20.dp, small = 4.dp, inset = 8.dp) + + assertEquals(BubbleCorners(12.dp, 12.dp, 12.dp, 12.dp), corners) + } + + @Test + fun `chip on a flattened photo corner floors at the small radius`() { + // A photo grouped under an earlier message from the same sender: its top-start corner is + // the flattened one, and the chip's nested corner can't go below that. + val corners = photoQuoteCorners(photoTopStart = 4.dp, large = 20.dp, small = 4.dp, inset = 8.dp) + + assertEquals(BubbleCorners(4.dp, 12.dp, 12.dp, 12.dp), corners) + } +}