From 6fe81397aa156096232040fe07483518e43054d6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 15:02:42 -0400 Subject: [PATCH 1/2] feat(messenger): show the linked message when a link lands in a gated group A non-member below a group's balance requirement never fetches the transcript, so a message link had nothing to jump to and opened on the placeholder rows alone. The landing now asks GetMessage (FULL_OR_REDACTED) for the one message, without storing it, and draws it as a single row over the blur: sender name and avatar, the bubble with its body blurred, and the reaction pills. The row has no capabilities and cannot be reacted to. It flashes once on arrival; the back chip behaves as on a normal landing. ContentBubble gains `redacted`, which blurs a text body and drops it from the accessibility tree. The blur and its pre-Android 12 fallback move out of MessageLinkCard into a shared Modifier.redactedText(). Design: Figma node 11777:207, flow 6 of the message-links design note. --- .../app/messenger/internal/ChatViewModel.kt | 52 +++++++- .../app/messenger/internal/GatedLanding.kt | 60 +++++++++ .../internal/screens/MessengerScreen.kt | 13 ++ .../screens/components/GatedLandingRow.kt | 126 ++++++++++++++++++ .../internal/screens/components/MessageRow.kt | 2 +- .../messenger/internal/GatedLandingTest.kt | 94 +++++++++++++ .../shared/chat/models/ChatListItem.kt | 7 + .../flipcash/shared/chat/ui/MessageBubble.kt | 9 +- .../shared/chat/ui/MessageLinkCard.kt | 23 +--- .../flipcash/shared/chat/ui/RedactedText.kt | 36 +++++ .../flipcash/shared/chat/ChatCoordinator.kt | 8 ++ .../internal/delegates/MessagingDelegate.kt | 4 + 12 files changed, 408 insertions(+), 26 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GatedLanding.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedLandingRow.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GatedLandingTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/RedactedText.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 1331fbbd4..bca3a9645 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -455,6 +455,12 @@ internal class ChatViewModel @AssistedInject constructor( * group's history, so [messageLinkLanding] waits on the group for such a link. */ val linkedAsGroup: Boolean = false, + /** + * The linked message, drawn alone over the blurred transcript when a message link lands in + * a group that withholds its transcript from the viewer (flow 6). See [gatedLandingBubble]. + * Null for any other visit, and cleared once the viewer can read the transcript itself. + */ + val gatedLanding: ChatListItem.ContentBubble? = null, /** * Where the "N Unread Messages" divider sits, read once per visit from the viewer's stored * READ pointer. Never re-read: the list advances that pointer as the reader scrolls, and a @@ -1100,6 +1106,9 @@ internal class ChatViewModel @AssistedInject constructor( /** A jump whose message is neither stored nor on the server: nothing to scroll to. */ data object JumpUnreachable : Event + + /** The row a withheld landing draws over the blur changed. See [State.gatedLanding]. */ + data class GatedLandingChanged(val item: ChatListItem.ContentBubble?) : Event } /** @@ -1228,6 +1237,9 @@ internal class ChatViewModel @AssistedInject constructor( * anyone it omits (a sender who left, a member past the cap) is asked for one profile at a time. * See SenderResolver. */ + /** The message a withheld landing peeked at; see [State.gatedLanding]. */ + private val gatedLandingMessage = MutableStateFlow(null) + @OptIn(ExperimentalCoroutinesApi::class) private val senderProfiles: StateFlow?> = stateFlow.map { it.chatType == ChatType.GROUP } @@ -3049,11 +3061,27 @@ internal class ChatViewModel @AssistedInject constructor( } .onEach { (chatId, landing) -> when (landing) { - MessageLinkLanding.Readable -> resolveJump(chatId, openAtMessageId) - MessageLinkLanding.Withheld -> dispatchEvent(Event.JumpUnreachable) + MessageLinkLanding.Readable -> { + gatedLandingMessage.value = null + resolveJump(chatId, openAtMessageId) + } + MessageLinkLanding.Withheld -> { + dispatchEvent(Event.JumpUnreachable) + // A group the viewer is outside of still shows the linked message + // itself, redacted as the server sees fit; nothing else is fetched. + val state = stateFlow.value + if (state.obscuresTranscript && !state.chatNotFound) { + chatCoordinator.peekMessage(chatId, openAtMessageId) + .onSuccess { gatedLandingMessage.value = it } + } + } } } .launchIn(viewModelScope) + + gatedLandingItems() + .onEach { dispatchEvent(Event.GatedLandingChanged(it)) } + .launchIn(viewModelScope) } // Read once per chat id. The list holds read reporting until this lands, so the pointer @@ -3563,6 +3591,25 @@ internal class ChatViewModel @AssistedInject constructor( } } + /** [gatedLandingMessage] as the row it draws, named once its sender's profile resolves. */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun gatedLandingItems(): Flow = + gatedLandingMessage + .flatMapLatest { message -> + if (message == null) return@flatMapLatest flowOf(null) + val senderId = message.senderId?.takeIf { !message.isFromSelf } + senderId?.let { chatCoordinator.requestSenderProfile(it) } + chatCoordinator.observeSenderProfiles() + .onStart { chatCoordinator.currentSenderProfiles()?.let { emit(it) } } + .map { profiles -> senderId?.let { profiles[it.hexEncodedString()] } } + .onStart { emit(null) } + .distinctUntilChanged() + .map { profile -> + gatedLandingBubble(message, profile, resources.getString(R.string.title_unnamedUser)) + } + } + .distinctUntilChanged() + /** * Resolves the walk's bound for a jump to [messageId] and hands it to the list. A message the * device never stored is paged in from the server first, back from the oldest stored message so @@ -3926,6 +3973,7 @@ internal class ChatViewModel @AssistedInject constructor( state.copy(jumpTarget = null, jumpBudget = null, openingAtMessage = false) } Event.JumpUnreachable -> { state -> state.copy(openingAtMessage = false) } + is Event.GatedLandingChanged -> { state -> state.copy(gatedLanding = event.item) } is Event.UnreadBoundaryResolved -> { state -> state.copy( unreadBoundary = event.boundary, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GatedLanding.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GatedLanding.kt new file mode 100644 index 000000000..041aabe94 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GatedLanding.kt @@ -0,0 +1,60 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.handle +import com.flipcash.services.models.nameOrHandle +import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity + +/** + * The one row a message link draws when it lands in a group whose transcript is withheld from the + * viewer (flow 6): [message] as `GetMessage` in `FULL_OR_REDACTED` answered it, over the blurred + * placeholder transcript. + * + * Display only. It carries no capabilities and cannot be reacted to, so nothing selects, replies + * to, copies or reacts to it; [ChatListItem.ContentBubble.redacted] keeps a withheld body blurred. + * The sender, time and reactions are the server's whole answer even to a redacted reader, so they + * draw as they are. + * + * Null for a message with nothing to draw: deleted, or no content at all. + * + * @param senderProfile the sender's profile once it has resolved, or null to draw the row without + * a name until it does. + * @param unnamed what the name line says for a sender with neither a name nor a handle. + */ +internal fun gatedLandingBubble( + message: ChatMessage, + senderProfile: UserProfile?, + unnamed: String, +): ChatListItem.ContentBubble? { + val content = message.content.firstOrNull() ?: return null + if (content is MessageContent.Deleted) return null + val senderId = message.senderId?.takeIf { !message.isFromSelf } + val sender = senderId?.let { id -> + senderProfile?.let { profile -> + SenderIdentity( + userId = id, + displayName = nameOrHandle(profile.displayName, profile.handle) ?: unnamed, + initialsName = nameOrHandle(profile.displayName, profile.handle), + picture = profile.profilePicture, + ) + } + } + return ChatListItem.ContentBubble( + messageId = message.messageId, + contentIndex = 0, + content = content, + isFromSelf = message.isFromSelf, + timestamp = message.timestamp, + isEdited = message.lastEditedTs != null, + capabilities = emptySet(), + sender = sender, + senderId = senderId, + reactionPills = MessageReactions.from(message.reactions).pills, + canReact = false, + redacted = message.redacted, + ) +} 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 28bc2ca4c..7244c6865 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 @@ -66,6 +66,7 @@ import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge +import com.flipcash.app.messenger.internal.screens.components.GatedLandingRow import com.flipcash.app.messenger.internal.screens.components.JoinRequestsAnchor import com.flipcash.app.messenger.internal.screens.components.ReturnToChatChip import com.flipcash.app.messenger.internal.screens.components.MessageList @@ -549,6 +550,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } } + // Outside the blur: a message link that lands where the transcript is withheld still shows + // the message it points at, over the placeholder rows, between the bars. + state.gatedLanding?.let { item -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(overlapPadding), + contentAlignment = Alignment.Center, + ) { + GatedLandingRow(item = item) + } + } } openPhotoId?.let { id -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedLandingRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedLandingRow.kt new file mode 100644 index 000000000..07c37a3b1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedLandingRow.kt @@ -0,0 +1,126 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.ChatAnimations +import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.chat.ui.ReactionPillRow +import com.flipcash.shared.chat.ui.bubbleMaxWidth +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import kotlinx.coroutines.delay + +/** + * The linked message a link lands on in a group that withholds its transcript (flow 6), drawn on + * its own above the blur. Sender, picture and reactions are sharp; the body is the server's + * placeholder, blurred by the bubble ([ChatListItem.ContentBubble.redacted]). + * + * Display only: the bubble is not interactive, and the avatar and pills answer nothing. It flashes + * once when it appears, as a jump in a readable transcript flashes the message it lands on. + */ +@Composable +internal fun GatedLandingRow( + item: ChatListItem.ContentBubble, + modifier: Modifier = Modifier, +) { + val attention = remember(item.messageId) { Animatable(0f) } + LaunchedEffect(item.messageId) { + attention.snapTo(1f) + delay(ChatAnimations.attentionHoldMs) + attention.animateTo(0f, ChatAnimations.attentionFade) + } + val sender = item.sender + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .testTag("gated_landing_row"), + ) { + if (sender != null && sender.displayName.isNotBlank()) { + Text( + modifier = Modifier.padding( + start = senderNameInset(!item.isFromSelf), + bottom = CodeTheme.dimens.grid.x1, + ), + text = sender.displayName, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + if (!item.isFromSelf) { + Box( + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x6), + ) { + if (sender != null) { + ContactAvatar( + image = sender.picture, + displayName = sender.initialsName.orEmpty(), + access = BlobAccessContext.profile(sender.userId), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } + } + } + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, + ) { + ContentBubble( + item = item, + position = BubblePosition.Solo, + interactive = false, + attention = { attention.value }, + ) + if (item.reactionPills.isNotEmpty()) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + ReactionPillRow( + pills = item.reactionPills, + canReact = false, + onToggle = {}, + onPillLongClick = {}, + onOpenPicker = {}, + modifier = Modifier + .align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart) + .width( + bubbleMaxWidth( + content = item.content, + rowWidth = maxWidth, + rowInset = CodeTheme.dimens.inset, + ), + ), + alignEnd = item.isFromSelf, + ) + } + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 2877af5c1..732a4b206 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -618,7 +618,7 @@ private fun SwipeToReplyAffordance( * does not, just the `grid.x1`. */ @Composable -private fun senderNameInset(showsGutter: Boolean): Dp = +internal fun senderNameInset(showsGutter: Boolean): Dp = if (showsGutter) { CodeTheme.dimens.staticGrid.x5 + CodeTheme.dimens.grid.x1 + CodeTheme.dimens.grid.x1 } else { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GatedLandingTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GatedLandingTest.kt new file mode 100644 index 000000000..a423adb16 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GatedLandingTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.ReactionSummary +import com.getcode.opencode.model.core.bytes +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class GatedLandingTest { + + private val senderId = UUID.randomUUID().bytes + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + joinedAt = null, + userId = null, + username = null, + ) + + private fun message( + content: MessageContent = MessageContent.Text("xxxx xxx xxxxx"), + redacted: Boolean = true, + reactions: ReactionSummary? = ReactionSummary( + messageId = 42, + reactions = listOf( + EmojiReaction(Emoji("🔥"), count = 3, selfReactor = null, sampleReactors = emptyList(), version = 1), + EmojiReaction(Emoji("👀"), count = 1, selfReactor = null, sampleReactors = emptyList(), version = 1), + ), + ), + ) = ChatMessage( + messageId = 42, + senderId = senderId, + content = listOf(content), + timestamp = Instant.fromEpochSeconds(1_760_000_000), + unreadSeq = 0, + reactions = reactions, + redacted = redacted, + ) + + @Test + fun `a redacted message lands as one display-only row with its sender and reactions`() { + val item = assertNotNull(gatedLandingBubble(message(), profile("Ada"), unnamed = "Unnamed")) + + assertEquals(42, item.messageId) + assertTrue(item.redacted) + assertEquals("Ada", item.sender?.displayName) + assertEquals(senderId, item.senderId) + assertEquals(listOf("🔥" to 3L, "👀" to 1L), item.reactionPills.map { it.emoji to it.count }) + assertTrue(item.capabilities.isEmpty()) + assertFalse(item.canReact) + assertNull(item.linkCard) + } + + @Test + fun `the row draws before the sender's profile resolves, unnamed until it does`() { + val item = assertNotNull(gatedLandingBubble(message(), senderProfile = null, unnamed = "Unnamed")) + + assertNull(item.sender) + assertEquals(senderId, item.senderId) + } + + @Test + fun `a sender with no name or handle reads as unnamed`() { + val item = assertNotNull(gatedLandingBubble(message(), profile(""), unnamed = "Unnamed")) + + assertEquals("Unnamed", item.sender?.displayName) + } + + @Test + fun `a message the server did not redact is not marked redacted`() { + val item = assertNotNull(gatedLandingBubble(message(redacted = false), profile("Ada"), "Unnamed")) + + assertFalse(item.redacted) + assertTrue(item.capabilities.isEmpty()) + } + + @Test + fun `a deleted message draws nothing`() { + assertNull(gatedLandingBubble(message(content = MessageContent.Deleted(Instant.fromEpochSeconds(1_760_000_100), deletedBy = null)), profile("Ada"), "Unnamed")) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 5557cb14f..45e479d70 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -185,6 +185,13 @@ sealed interface ChatListItem { val undecryptableHint: UndecryptableHint = UndecryptableHint.UpdateApp, /** What a photo message needs beyond its content to load and gate its photo. */ val photo: PhotoMessageContext? = null, + /** + * Whether [content] is the server's placeholder for a body the viewer may not read + * ([ChatMessage.redacted][com.flipcash.services.models.chat.ChatMessage.redacted]). Its + * text is drawn blurred and kept out of the accessibility tree, so it is never read, + * selected or copied. + */ + val redacted: Boolean = false, ) : TranscriptMessage { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the 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 8485230af..ce88df467 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 @@ -140,7 +140,8 @@ fun ContentBubble( ) { val actionHandler = LocalChatActionHandler.current val updateAction = LocalUpdateAppAction.current - val jumbo = remember(item) { item.rendersBareEmoji() } + // A redacted placeholder is never drawn bare: it is not the message, only its shape. + val jumbo = remember(item) { !item.redacted && item.rendersBareEmoji() } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val bubbleMaxWidth = bubbleMaxWidth( content = item.content, @@ -211,6 +212,7 @@ fun ContentBubble( onCardDoubleClick = onDoubleClick?.takeIf { interactive }, interactive = interactive, attention = attention, + redacted = item.redacted, ) // A tombstone is a text bubble with different words. Rendering it through the same @@ -490,6 +492,7 @@ private fun TextBubble( onCardDoubleClick: (() -> Unit)? = null, interactive: Boolean = true, attention: () -> Float = { 0f }, + redacted: Boolean = false, ) { if (jumbo) { JumboEmoji( @@ -638,10 +641,12 @@ private fun TextBubble( // a message is the selection bar's Copy action instead — the same trade WhatsApp makes. // The citation sits inside the bubble, above the body, so the two move together and the // reply reads as one message rather than as a quote with a message under it. + val redaction = if (redacted) Modifier.redactedText() else Modifier val bodyText = @Composable { Text( modifier = Modifier .padding(horizontal = bodyInset) + .then(redaction) .widestLine(measurer, laidOut, bodyStyle, inlineContent) .then( if (handles.isEmpty()) { @@ -653,7 +658,7 @@ private fun TextBubble( text = laidOut, inlineContent = inlineContent, style = bodyStyle, - color = bodyColor, + color = if (redacted) redactedTextColor(bodyColor) else bodyColor, onTextLayout = { bodyLayout = it }, ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt index 084aa3f10..de40fc18d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt @@ -1,6 +1,5 @@ package com.flipcash.shared.chat.ui -import android.os.Build import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -22,8 +21,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -291,21 +288,10 @@ private fun Excerpt(body: Body) { @Composable private fun ExcerptText(body: Body.Text) { Text( - modifier = Modifier.then( - when { - !body.redacted -> Modifier - canBlur -> Modifier - .blur(MessageLinkCardDefaults.REDACTED_BLUR, BlurredEdgeTreatment.Unbounded) - .clearAndSetSemantics {} - // `blur` draws nothing below Android 12; cover the text with a solid block instead. - else -> Modifier - .background(CodeTheme.colors.textSecondary.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) - .clearAndSetSemantics {} - } - ), + modifier = if (body.redacted) Modifier.redactedText() else Modifier, text = flattenExcerpt(body.text), style = MessageLinkCardDefaults.bodyStyle, - color = if (body.redacted && !canBlur) Color.Transparent else MessageLinkCardDefaults.EXCERPT, + color = if (body.redacted) redactedTextColor(MessageLinkCardDefaults.EXCERPT) else MessageLinkCardDefaults.EXCERPT, maxLines = MessageLinkCardDefaults.BODY_LINES, overflow = TextOverflow.Ellipsis, ) @@ -446,8 +432,6 @@ internal object MessageLinkCardDefaults { val THUMBNAIL_RADIUS = 8.dp val BAR_HEIGHT = 10.dp val BUTTON_HEIGHT = 44.dp - val REDACTED_BLUR = 6.dp - /** SF's chevron.right at 13 semibold is about 7 by 12. */ val CHEVRON_WIDTH = 7.dp val CHEVRON_HEIGHT = 12.dp @@ -563,6 +547,3 @@ private fun Preview_MessageLink_Deleted() = PreviewMessageCard(State.Deleted(pre private fun Preview_MessageLink_Unresolved() = PreviewMessageCard(State.Unresolved) // endregion - -/** `Modifier.blur` needs RenderEffect, which arrived in Android 12. */ -private val canBlur: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/RedactedText.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/RedactedText.kt new file mode 100644 index 000000000..7b0c710e0 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/RedactedText.kt @@ -0,0 +1,36 @@ +package com.flipcash.shared.chat.ui + +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme + +/** + * Hides a redacted message's text: the server's placeholder, standing in for a body the viewer may + * not read. Blurred where the platform can blur, and kept out of the accessibility tree either way, + * so the placeholder is never read out, selected or copied. Pair it with [redactedTextColor]. + */ +@Composable +internal fun Modifier.redactedText(): Modifier = if (canBlurRedactedText) { + blur(REDACTED_TEXT_BLUR, BlurredEdgeTreatment.Unbounded).clearAndSetSemantics {} +} else { + // `blur` draws nothing below Android 12; cover the text with a solid block instead. + background(CodeTheme.colors.textSecondary.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) + .clearAndSetSemantics {} +} + +/** What colour redacted text draws in: [color] where it blurs, nothing where a block covers it. */ +internal fun redactedTextColor(color: Color): Color = + if (canBlurRedactedText) color else Color.Transparent + +internal val REDACTED_TEXT_BLUR = 6.dp + +/** `Modifier.blur` needs RenderEffect, which arrived in Android 12. */ +private val canBlurRedactedText: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 9af84c0cb..5d4b03091 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -486,6 +486,14 @@ interface MessagingOperations { */ suspend fun readMessage(chatId: ChatId, messageId: Long): Result + /** + * [messageId] as a viewer outside the chat may see it: `GetMessage` in `FULL_OR_REDACTED`, which + * keeps the sender, timestamp and reactions and swaps a withheld body for a same-shape + * placeholder, flagged [ChatMessage.redacted]. Nothing is stored: this is for a group whose + * transcript is withheld, where storing it would put it under the blur. + */ + suspend fun peekMessage(chatId: ChatId, messageId: Long): Result + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 22ff62f3d..12ce09ab6 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -21,6 +21,7 @@ import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType @@ -424,6 +425,9 @@ class MessagingDelegate @Inject constructor( opener.open(chatId, selfId, peerId, listOf(fetched), stored = { null }).single() } + override suspend fun peekMessage(chatId: ChatId, messageId: Long): Result = + messagingController.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED) + /** Text, photos and replies to either are what DMs encrypt; anything else keeps the server's body. */ private fun MessageContent.pushText(): String? = when (this) { is MessageContent.Text -> text From f1ad3f279d9c95cd5d49921ce03ce750510acc26 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 15:18:21 -0400 Subject: [PATCH 2/2] fix(chat-ui): blur a redacted reply's placeholder text The reply branch of ContentBubble did not pass item.redacted to TextBubble, so a redacted reply landed on in a gated group drew the server's placeholder sharp. --- .../src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt | 1 + 1 file changed, 1 insertion(+) 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 ce88df467..28f548e98 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 @@ -295,6 +295,7 @@ fun ContentBubble( onCardDoubleClick = onDoubleClick?.takeIf { interactive }, interactive = interactive, attention = attention, + redacted = item.redacted, ) // Ciphertext reaches a bubble only when it didn't open; an opened message carries