From 28d3b5157a24920a0f979d67b6cc44299a272a68 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 11:38:00 -0400 Subject: [PATCH 1/4] feat(chat): render the share-profile widget bubble Draws WidgetContent.ShareProfile as a card with the sender's avatar, name and handle, and a Share button that runs the same share-link flow as the You tab. The name and picture come from the signed-in user's session profile, since the widget only ever describes them. An unrecognised widget variant draws as the existing unsupported-message bubble. The chat list previews a shared profile as "Shared a profile" ("You shared a profile" for the viewer's own). --- .../core/src/main/res/values/strings.xml | 3 + .../internal/screens/MessengerScreen.kt | 28 +++ .../flipcash/shared/chat/models/ChatAction.kt | 7 + .../shared/chat/ui/ChatSummaryMapping.kt | 13 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 28 ++- .../chat/ui/ShareProfileWidgetBubble.kt | 215 ++++++++++++++++++ .../shared/chat/ui/ChatSummaryPreviewTest.kt | 20 ++ .../ui/ShareProfileWidgetScreenshotTest.kt | 73 ++++++ 8 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 869d0d3066..a4bf9a284f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -973,6 +973,9 @@ %1$s: %2$s %1$s sent %2$s %1$s tipped %2$s + Shared a profile + You shared a profile + %1$s shared a profile Nothing yet Is Typing… 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 28a075b425..ab7bc70a8b 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 @@ -46,6 +46,12 @@ import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.getcode.ui.components.BlurredContent import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.shareable.LocalShareController +import com.flipcash.app.shareable.Shareable +import com.flipcash.features.messenger.R +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.theme.ScaffoldBarPlacement @@ -68,6 +74,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ExternalLinkUriHandler(context, uriHandler) } + val userManager = LocalUserManager.current + val shareController = LocalShareController.current + val scope = rememberCoroutineScope() + val hazeState = rememberHazeState() // Measured by the bar and read by the transcript: the blur has to cover the bar's own // height, which the selection and editing modes change. @@ -231,6 +241,24 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId)) } + // The reader's own link, shared the way the You tab shares it: the handle form when they + // have one, titled with their name. + ChatAction.ShareOwnProfile -> { + userManager?.accountId?.let { userId -> + val profile = userManager.state.value.userProfile + scope.launch { + shareController.present( + Shareable.TipCard( + userId = userId, + title = profile?.displayName?.takeIf { it.isNotBlank() } + ?.let { context.getString(R.string.label_tipUser, it) }, + username = profile?.username, + ) + ) + } + } + } + ChatAction.JoinChat -> viewModel.dispatchEvent(ChatViewModel.Event.JoinChat) ChatAction.InviteToGroup -> { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 3e5712c414..e52eb53ad4 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -44,6 +44,13 @@ sealed interface ChatAction { * the tap, and the handler decides where it leads once it has. */ data class OpenMention(val username: String) : ChatAction + + /** + * Shares the reader's own profile link, from the Share button on a share-profile widget. A verb + * only: the widget always describes the signed-in user, so the handler reads who that is from + * the session rather than from the message. + */ + data object ShareOwnProfile : ChatAction data object ViewProfile : ChatAction /** diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 5282068175..e7281ef9c2 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -4,6 +4,7 @@ import com.flipcash.core.R import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.handle import com.flipcash.shared.chat.ChatSummary import com.getcode.opencode.model.core.ID @@ -183,8 +184,16 @@ private fun MessageContent.previewText( is MessageContent.Media -> null is MessageContent.System -> null - // TODO: preview text for widgets once the design settles. - is MessageContent.Widget -> null + // A widget this client can't draw previews as nothing, as an undecryptable message does. + is MessageContent.Widget -> when (widget) { + is WidgetContent.ShareProfile -> when { + sentBySelf -> resources.getString(R.string.label_chat_preview_sharedProfile_you) + senderName != null -> + resources.getString(R.string.label_chat_preview_sharedProfile_bySender, senderName) + else -> resources.getString(R.string.label_chat_preview_sharedProfile) + } + WidgetContent.Unsupported -> null + } } private const val MAX_REPLY_UNWRAP_DEPTH = 4 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 9d7114a4cb..aadda3931f 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 @@ -63,6 +63,7 @@ import androidx.paging.compose.LazyPagingItems import com.flipcash.app.core.ui.TokenIconWithName import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.LinkCard @@ -270,8 +271,29 @@ fun ContentBubble( // TODO is MessageContent.Media -> Unit is MessageContent.System -> Unit - // TODO: render ShareProfile; an unsupported widget variant draws nothing until then. - is MessageContent.Widget -> Unit + // A widget variant this client doesn't know draws as any message it can't show. + is MessageContent.Widget -> when (val widget = content.widget) { + is WidgetContent.ShareProfile -> ShareProfileWidgetBubble( + modifier = modifier, + username = widget.username, + isFromSelf = item.isFromSelf, + position = position, + maxWidth = bubbleMaxWidth, + onShare = if (interactive) { + { actionHandler(ChatAction.ShareOwnProfile) } + } else { + null + }, + onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, + attention = attention, + ) + + WidgetContent.Unsupported -> UndecryptableBubble( + modifier = modifier, + hint = UndecryptableHint.UpdateApp, + ) + } } } } @@ -859,7 +881,7 @@ internal object BubbleDefaults { } @Composable -private fun Bubble( +internal fun Bubble( isFromSelf: Boolean, position: BubblePosition, maxWidth: Dp, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt new file mode 100644 index 0000000000..933a1b2885 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt @@ -0,0 +1,215 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.services.models.chat.BlobAccessContext +import com.getcode.opencode.model.core.ID +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +/** + * The share-profile widget as it sits in a transcript: whose profile it is comes from the session, + * because the widget always describes the signed-in user and carries only [username] on the wire. + * + * With no profile in the session (a preview, or the moment before it restores) the name falls back + * to [username], and the avatar to that name's initials. + */ +@Composable +internal fun ShareProfileWidgetBubble( + username: String, + isFromSelf: Boolean, + position: BubblePosition, + maxWidth: Dp, + onShare: (() -> Unit)?, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + attention: () -> Float = { 0f }, +) { + val userManager = LocalUserManager.current + val profile: UserProfile? = userManager?.state?.collectAsState()?.value?.userProfile + ShareProfileWidgetBubble( + displayName = profile?.displayName?.takeIf { it.isNotBlank() } ?: username, + username = username, + profilePicture = profile?.profilePicture, + userId = profile?.userId ?: userManager?.accountId, + isFromSelf = isFromSelf, + position = position, + maxWidth = maxWidth, + onShare = onShare, + modifier = modifier, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + attention = attention, + ) +} + +/** Node 10588:1979. */ +@Composable +internal fun ShareProfileWidgetBubble( + displayName: String, + username: String, + profilePicture: MediaItem?, + userId: ID?, + isFromSelf: Boolean, + position: BubblePosition, + maxWidth: Dp, + onShare: (() -> Unit)?, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + attention: () -> Float = { 0f }, +) { + val shape = bubbleShape(position, isFromSelf) + val width = minOf(maxWidth, ShareProfileWidgetDefaults.WIDTH) + Bubble( + isFromSelf = isFromSelf, + position = position, + minWidth = width, + maxWidth = width, + bare = true, + shape = shape, + horizontalPadding = CodeTheme.dimens.staticGrid.x3, + verticalPadding = CodeTheme.dimens.staticGrid.x3, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + modifier = modifier + .background(White05, shape) + .border(CodeTheme.dimens.border, ShareProfileWidgetDefaults.STROKE, shape), + attention = attention, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.SECTION_GAP), + ) { + Column( + modifier = Modifier.widthIn(max = ShareProfileWidgetDefaults.IDENTITY_WIDTH), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), + ) { + ContactAvatar( + image = profilePicture, + displayName = displayName, + access = BlobAccessContext.profile(userId), + modifier = Modifier + .size(ShareProfileWidgetDefaults.AVATAR) + .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, CircleShape) + .clip(CircleShape), + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.LINE_GAP), + ) { + Text( + text = displayName, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@$username", + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain.copy(alpha = 0.5f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + CodeButton( + modifier = Modifier + .fillMaxWidth() + .height(CodeTheme.dimens.staticGrid.x11), + onClick = { onShare?.invoke() }, + enabled = onShare != null, + buttonState = ButtonState.Filled, + shape = RoundedCornerShape(ShareProfileWidgetDefaults.BUTTON_RADIUS), + overrideContentPadding = true, + contentPadding = PaddingValues(0.dp), + ) { + // CodeButton stacks its content, so the pair needs a row of its own. + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_share_os), + contentDescription = null, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + Text( + modifier = Modifier.padding(start = CodeTheme.dimens.staticGrid.x1), + text = stringResource(R.string.action_shareProfile), + style = CodeTheme.typography.textMedium, + ) + } + } + } + } +} + +internal object ShareProfileWidgetDefaults { + /** The card's width in the design. A narrower transcript shrinks it. */ + val WIDTH = 290.dp + val IDENTITY_WIDTH = 210.dp + val AVATAR = 64.dp + val SECTION_GAP = 18.dp + val BUTTON_RADIUS = 6.dp + val LINE_GAP = 2.dp + + /** The card's outline: white at 8%, the nearest token to the design's 7%. */ + val STROKE = Color.White.copy(alpha = 0.08f) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ShareProfileWidgetBubble() { + ShareProfileWidgetBubble( + displayName = "Brad Burnham", + username = "brad_burnham_2", + profilePicture = null, + userId = null, + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = 320.dp, + onShare = {}, + modifier = Modifier.padding(16.dp), + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 4f91601961..416ba6dfdf 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -9,6 +9,7 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent import com.flipcash.shared.chat.ChatSummary import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.toFiat @@ -349,4 +350,23 @@ class ChatSummaryPreviewTest { assertTrue(reference.hasMessages) assertNull(reference.lastMessagePreview) } + + @Test + fun `a shared profile previews as such, and the viewer's own says so`() { + every { + resources.getString(R.string.label_chat_preview_sharedProfile) + } returns "Shared a profile" + every { + resources.getString(R.string.label_chat_preview_sharedProfile_you) + } returns "You shared a profile" + val widget = MessageContent.Widget(WidgetContent.ShareProfile("brad")) + + assertEquals("Shared a profile", preview(listOf(widget))) + assertEquals("You shared a profile", preview(listOf(widget), senderId = self)) + } + + @Test + fun `a widget this client can't draw has nothing to preview`() { + assertNull(preview(listOf(MessageContent.Widget(WidgetContent.Unsupported)))) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt new file mode 100644 index 0000000000..4072f82156 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt @@ -0,0 +1,73 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import com.getcode.theme.CodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders the share-profile widget bubble to a PNG for + * comparison with the design (node 10416:1576). Not an assertion test — it writes to + * `build/screenshots/`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w330dp-h260dp-xxhdpi") +class ShareProfileWidgetScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersShareProfileWidget() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashTheme { + Column( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + ShareProfileWidgetBubble( + displayName = "Brad Burnham", + username = "brad_burnham_2", + profilePicture = null, + userId = null, + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = 290.dp, + onShare = {}, + ) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, "share_profile_widget.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} From 512f576f275485ef1bec25b1101f545fac834a4f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:02:34 -0400 Subject: [PATCH 2/4] fix(chat): treat share-profile widgets as server-only messages Widgets are sent by the server, never by a client. Give them no message actions or reactions (as with system notices), use one sender-independent list preview, and make encoding a widget an error. --- .../flipcash/core/src/main/res/values/strings.xml | 2 -- .../flipcash/shared/chat/ui/ChatSummaryMapping.kt | 7 +------ .../shared/chat/ui/ChatSummaryPreviewTest.kt | 7 ++----- .../com/flipcash/shared/chat/MessageCapability.kt | 10 ++++++---- .../flipcash/shared/chat/MessageCapabilityTest.kt | 13 +++++++++++++ .../network/extensions/LocalToProtobuf.kt | 15 ++------------- .../extensions/WidgetAndNeverMappingTest.kt | 9 +++++---- 7 files changed, 29 insertions(+), 34 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a4bf9a284f..c162722169 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -974,8 +974,6 @@ %1$s sent %2$s %1$s tipped %2$s Shared a profile - You shared a profile - %1$s shared a profile Nothing yet Is Typing… diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index e7281ef9c2..e588d4f309 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -186,12 +186,7 @@ private fun MessageContent.previewText( // A widget this client can't draw previews as nothing, as an undecryptable message does. is MessageContent.Widget -> when (widget) { - is WidgetContent.ShareProfile -> when { - sentBySelf -> resources.getString(R.string.label_chat_preview_sharedProfile_you) - senderName != null -> - resources.getString(R.string.label_chat_preview_sharedProfile_bySender, senderName) - else -> resources.getString(R.string.label_chat_preview_sharedProfile) - } + is WidgetContent.ShareProfile -> resources.getString(R.string.label_chat_preview_sharedProfile) WidgetContent.Unsupported -> null } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 416ba6dfdf..5e33fc8422 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -352,17 +352,14 @@ class ChatSummaryPreviewTest { } @Test - fun `a shared profile previews as such, and the viewer's own says so`() { + fun `a shared profile previews the same whoever the sender is`() { every { resources.getString(R.string.label_chat_preview_sharedProfile) } returns "Shared a profile" - every { - resources.getString(R.string.label_chat_preview_sharedProfile_you) - } returns "You shared a profile" val widget = MessageContent.Widget(WidgetContent.ShareProfile("brad")) assertEquals("Shared a profile", preview(listOf(widget))) - assertEquals("You shared a profile", preview(listOf(widget), senderId = self)) + assertEquals("Shared a profile", preview(listOf(widget), senderId = self)) } @Test diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index fcad28120c..fae1e71dc0 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -103,6 +103,7 @@ data class MessagePolicy( * | Another participant's cash or tip message | Reply, Report | * | A tombstone | none | * | A system notice | none | + * | A widget (server-sent) | none | * * Report follows one rule: anything a participant sent can be reported, and anything the server * wrote, or that no longer exists, cannot. Your own messages are left out because reporting one is @@ -159,8 +160,9 @@ private fun resolveForParticipant( } } - // Server-authored notices, not a participant's message. - if (contents.all { it is MessageContent.System }) return emptySet() + // Server-authored notices and widgets, not a participant's message: nothing to copy, reply to + // or report, and no user sender to attribute either to. + if (contents.all { it is MessageContent.System || it is MessageContent.Widget }) return emptySet() val hasText = contents.any { it is MessageContent.Text || it is MessageContent.Reply } @@ -211,7 +213,7 @@ private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean = * * Reactions have no edit/delete-style windows and no report-only carve-out: anyone's message is * reactable, own or another participant's, text or cash. Only two things rule a message out — - * a system notice (nothing a participant sent) and an unconfirmed send (`eventSequence == 0`, + * a system notice or widget (nothing a participant sent) and an unconfirmed send (`eventSequence == 0`, * the same guard [resolveForParticipant] uses: no valid request can name a message the server * has not acknowledged). A tombstone has no content left ([MessageContent.Deleted] clears it), * so the empty-content check below already excludes it. @@ -221,6 +223,6 @@ fun canReact(message: ChatMessage): Boolean { if (contents.isEmpty()) return false if (contents.any { it is MessageContent.Deleted }) return false if (message.eventSequence == 0L) return false - if (contents.all { it is MessageContent.System }) return false + if (contents.all { it is MessageContent.System || it is MessageContent.Widget }) return false return true } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index aa51bc0cd3..d6bfd9a394 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import org.junit.Test @@ -118,6 +119,12 @@ class MessageCapabilityTest { isFromSelf = false, ) assertEquals(emptySet(), resolveCapabilities(theirSystemNotice)) + + val widget = message( + listOf(MessageContent.Widget(WidgetContent.ShareProfile("brad"))), + isFromSelf = false, + ) + assertEquals(emptySet(), resolveCapabilities(widget)) } @Test @@ -378,6 +385,12 @@ class MessageCapabilityTest { assertEquals(false, canReact(system)) } + @Test + fun `a widget is not reactable`() { + val widget = message(listOf(MessageContent.Widget(WidgetContent.ShareProfile("brad")))) + assertEquals(false, canReact(widget)) + } + @Test fun `an unconfirmed send is not reactable`() { assertEquals(false, canReact(text(eventSequence = 0))) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index d484fd9d31..00ea3d07a4 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -14,7 +14,6 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent -import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState @@ -161,18 +160,8 @@ internal fun MessageContent.asContent(): MessagingModel.Content { .apply { if (caption != null) setCaption(MessagingModel.TextContent.newBuilder().setText(caption.text)) } ) .build() - is MessageContent.Widget -> when (val w = widget) { - is WidgetContent.ShareProfile -> MessagingModel.Content.newBuilder() - .setWidget( - MessagingModel.WidgetContent.newBuilder() - .setShareProfile( - MessagingModel.ShareProfileWidget.newBuilder().setUsername(w.username.asUsername()) - ) - ) - .build() - // Received-only: there is nothing to send for a variant this client can't read. - WidgetContent.Unsupported -> MessagingModel.Content.getDefaultInstance() - } + // Server-authored and receive-only: no client path builds one, so reaching this is a bug. + is MessageContent.Widget -> error("Widget messages are receive-only and are never sent by a client") is MessageContent.System -> MessagingModel.Content.newBuilder() .setSystem(MessagingModel.SystemContent.newBuilder().setFallbackText(fallbackText)) .build() diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt index 7b98ade634..8356ffe785 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.WidgetContent import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class WidgetAndNeverMappingTest { @@ -38,10 +39,10 @@ class WidgetAndNeverMappingTest { } @Test - fun `share profile widget round trips through the wire type`() { - val domain = MessageContent.Widget(WidgetContent.ShareProfile("alice")) - - assertEquals(domain, domain.asContent().toMessageContent()) + fun `a widget can't be built for sending`() { + assertFailsWith { + MessageContent.Widget(WidgetContent.ShareProfile("alice")).asContent() + } } @Test From d42bcedaca3b48be0ed8e9122579eec813f13b30 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:08:54 -0400 Subject: [PATCH 3/4] feat(chat): resolve the share-profile widget's user by username The widget carries only a username, which now decides whose profile the card shows and shares. The signed-in user's own comes from the session; anyone else's goes through the by-handle lookup that person link cards use. Until it answers, or if it fails, the card shows the username and its initials, and Share stays off because it needs the resolved id. --- .../internal/screens/MessengerScreen.kt | 26 ++-- .../flipcash/shared/chat/models/ChatAction.kt | 11 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 6 +- .../chat/ui/ShareProfileWidgetBubble.kt | 135 ++++++++++++++++-- .../ui/ShareProfileWidgetScreenshotTest.kt | 47 ++++-- 5 files changed, 175 insertions(+), 50 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 ab7bc70a8b..8e568f6a0d 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 @@ -241,22 +241,16 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId)) } - // The reader's own link, shared the way the You tab shares it: the handle form when they - // have one, titled with their name. - ChatAction.ShareOwnProfile -> { - userManager?.accountId?.let { userId -> - val profile = userManager.state.value.userProfile - scope.launch { - shareController.present( - Shareable.TipCard( - userId = userId, - title = profile?.displayName?.takeIf { it.isNotBlank() } - ?.let { context.getString(R.string.label_tipUser, it) }, - username = profile?.username, - ) - ) - } - } + // The profile a share-profile widget names, shared the way a profile's Share shortcut + // shares it (`rememberProfileShare`): the handle form, titled with their name. + is ChatAction.ShareProfile -> scope.launch { + shareController.present( + Shareable.TipCard( + userId = action.userId, + title = action.displayName?.let { context.getString(R.string.label_tipUser, it) }, + username = action.username, + ) + ) } ChatAction.JoinChat -> viewModel.dispatchEvent(ChatViewModel.Event.JoinChat) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index e52eb53ad4..2ed6e2b868 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -46,11 +46,14 @@ sealed interface ChatAction { data class OpenMention(val username: String) : ChatAction /** - * Shares the reader's own profile link, from the Share button on a share-profile widget. A verb - * only: the widget always describes the signed-in user, so the handler reads who that is from - * the session rather than from the message. + * Shares the profile a share-profile widget describes, from its Share button. The widget + * carries only a handle; [userId] is the id of the profile that handle resolved to. */ - data object ShareOwnProfile : ChatAction + data class ShareProfile( + val userId: ID, + val username: String, + val displayName: String?, + ) : ChatAction data object ViewProfile : ChatAction /** 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 aadda3931f..bfbe7ac46d 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 @@ -279,11 +279,7 @@ fun ContentBubble( isFromSelf = item.isFromSelf, position = position, maxWidth = bubbleMaxWidth, - onShare = if (interactive) { - { actionHandler(ChatAction.ShareOwnProfile) } - } else { - null - }, + onShare = if (interactive) actionHandler else null, onLongClick = onLongClick?.takeIf { interactive }, onDoubleClick = onDoubleClick?.takeIf { interactive }, attention = attention, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt index 933a1b2885..b7d31ce219 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt @@ -16,8 +16,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +36,11 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.core.ui.shimmer +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.LocalLinkCardResolution import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.MediaItem @@ -39,16 +48,19 @@ import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.services.models.chat.BlobAccessContext import com.getcode.opencode.model.core.ID import com.getcode.theme.CodeTheme +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import com.getcode.theme.White05 import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton /** - * The share-profile widget as it sits in a transcript: whose profile it is comes from the session, - * because the widget always describes the signed-in user and carries only [username] on the wire. + * The share-profile widget as it sits in a transcript. The widget carries only [username], which + * decides whose profile the card shows and shares: the signed-in user's own comes from the session, + * anyone else's from the same by-handle lookup a person link card uses. * - * With no profile in the session (a preview, or the moment before it restores) the name falls back - * to [username], and the avatar to that name's initials. + * Until that lookup answers, and if it fails, the name is [username] and the avatar its initials. + * Share needs the profile's id, so it stays off until the profile has resolved. */ @Composable internal fun ShareProfileWidgetBubble( @@ -56,23 +68,35 @@ internal fun ShareProfileWidgetBubble( isFromSelf: Boolean, position: BubblePosition, maxWidth: Dp, - onShare: (() -> Unit)?, + onShare: ((ChatAction.ShareProfile) -> Unit)?, modifier: Modifier = Modifier, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, ) { - val userManager = LocalUserManager.current - val profile: UserProfile? = userManager?.state?.collectAsState()?.value?.userProfile + val profile = rememberWidgetProfile(username) ShareProfileWidgetBubble( - displayName = profile?.displayName?.takeIf { it.isNotBlank() } ?: username, + displayName = (profile as? WidgetProfile.Resolved)?.displayName ?: username, username = username, - profilePicture = profile?.profilePicture, - userId = profile?.userId ?: userManager?.accountId, + profilePicture = (profile as? WidgetProfile.Resolved)?.picture, + userId = (profile as? WidgetProfile.Resolved)?.userId, + loading = profile is WidgetProfile.Loading, isFromSelf = isFromSelf, position = position, maxWidth = maxWidth, - onShare = onShare, + onShare = (profile as? WidgetProfile.Resolved)?.let { resolved -> + onShare?.let { share -> + { + share( + ChatAction.ShareProfile( + userId = resolved.userId, + username = username, + displayName = resolved.displayName, + ) + ) + } + } + }, modifier = modifier, onLongClick = onLongClick, onDoubleClick = onDoubleClick, @@ -80,6 +104,52 @@ internal fun ShareProfileWidgetBubble( ) } +/** Whose card it is, as far as it is known. */ +internal sealed interface WidgetProfile { + data object Loading : WidgetProfile + data object Failed : WidgetProfile + data class Resolved( + val userId: ID, + val displayName: String?, + val picture: MediaItem?, + ) : WidgetProfile +} + +/** [displayName] and [picture] of whoever [username] names, from the session or the lookup. */ +@Composable +private fun rememberWidgetProfile(username: String): WidgetProfile { + val userManager = LocalUserManager.current + val own: UserProfile? = userManager?.state?.collectAsState()?.value?.userProfile + val ownId = userManager?.accountId + if (own != null && ownId != null && own.username.equals(username.removePrefix("@"), ignoreCase = true)) { + return WidgetProfile.Resolved( + userId = own.userId ?: ownId, + displayName = own.displayName.takeIf { it.isNotBlank() }, + picture = own.profilePicture, + ) + } + + val resolution = LocalLinkCardResolution.current + val revision by resolution.revision.collectAsState() + val identity = remember(username) { LinkCard.User.Identity.ByUsername(username) } + val seed = remember(identity) { userCard(identity, LinkCard.User.State.Loading) } + var card by remember(identity) { mutableStateOf(resolution.peek(seed) ?: seed) } + LaunchedEffect(identity, revision) { card = resolution.resolve(seed) } + + return when (val state = (card as? LinkCard.User)?.state) { + is LinkCard.User.State.Resolved -> WidgetProfile.Resolved( + userId = state.userId, + displayName = state.profile.displayName.takeIf { it.isNotBlank() }, + picture = state.profile.profilePicture, + ) + LinkCard.User.State.NotFound -> WidgetProfile.Failed + else -> WidgetProfile.Loading + } +} + +private fun userCard(identity: LinkCard.User.Identity, state: LinkCard.User.State) = + LinkCard.User(url = "", start = 0, end = 0, identity = identity, state = state) + /** Node 10588:1979. */ @Composable internal fun ShareProfileWidgetBubble( @@ -91,6 +161,7 @@ internal fun ShareProfileWidgetBubble( position: BubblePosition, maxWidth: Dp, onShare: (() -> Unit)?, + loading: Boolean = false, modifier: Modifier = Modifier, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, @@ -144,6 +215,7 @@ internal fun ShareProfileWidgetBubble( textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = if (loading) Modifier.shimmer(RoundedCornerShape(4.dp)) else Modifier, ) Text( text = "@$username", @@ -197,10 +269,43 @@ internal object ShareProfileWidgetDefaults { val STROKE = Color.White.copy(alpha = 0.08f) } +/** A lookup that answers with [state], for the previews and the screenshot test. */ +internal class FixedUserResolution(private val state: LinkCard.User.State) : LinkCardResolution { + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard? = (card as? LinkCard.User)?.copy(state = state) + override suspend fun resolve(card: LinkCard): LinkCard = peek(card) ?: card +} + +@Composable +internal fun PreviewShareProfileWidget(state: LinkCard.User.State, username: String) { + CompositionLocalProvider(LocalLinkCardResolution provides FixedUserResolution(state)) { + ShareProfileWidgetBubble( + username = username, + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = 320.dp, + onShare = {}, + modifier = Modifier.padding(16.dp), + ) + } +} + +/** Someone else's profile, resolved by handle. */ @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable private fun Preview_ShareProfileWidgetBubble() { + PreviewShareProfileWidget( + state = previewUserResolved(name = "Brad Burnham", handle = "@brad_burnham_2", blurHash = null), + username = "brad_burnham_2", + ) +} + +/** The signed-in user's own profile, which needs no lookup. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ShareProfileWidgetBubble_Own() { ShareProfileWidgetBubble( displayName = "Brad Burnham", username = "brad_burnham_2", @@ -213,3 +318,11 @@ private fun Preview_ShareProfileWidgetBubble() { modifier = Modifier.padding(16.dp), ) } + +/** The lookup found nobody or failed: the handle stands in for the name, and Share is off. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ShareProfileWidgetBubble_LookupFailed() { + PreviewShareProfileWidget(state = LinkCard.User.State.NotFound, username = "brad_burnham_2") +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt index 4072f82156..5138f08cd4 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt @@ -9,10 +9,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashTheme +import com.flipcash.shared.chat.models.LinkCard import com.getcode.theme.CodeTheme import org.junit.Rule import org.junit.Test @@ -24,19 +26,45 @@ import java.io.File /** * Renders the share-profile widget bubble to a PNG for - * comparison with the design (node 10416:1576). Not an assertion test — it writes to + * comparison with the design (node 10588:1979). Not an assertion test — it writes to * `build/screenshots/`. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [34], qualifiers = "w330dp-h260dp-xxhdpi") +@Config(sdk = [34], qualifiers = "w360dp-h340dp-xxhdpi") class ShareProfileWidgetScreenshotTest { @get:Rule val composeRule = createAndroidComposeRule() @Test - fun rendersShareProfileWidget() { + fun rendersOwnProfile() = render("share_profile_widget.png") { + ShareProfileWidgetBubble( + displayName = "Brad Burnham", + username = "brad_burnham_2", + profilePicture = null, + userId = null, + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = 290.dp, + onShare = {}, + ) + } + + @Test + fun rendersOtherUserResolved() = render("share_profile_widget_other.png") { + PreviewShareProfileWidget( + state = previewUserResolved(name = "Satoshi Nakamoto", handle = "@satoshi", blurHash = null), + username = "satoshi", + ) + } + + @Test + fun rendersLookupFailed() = render("share_profile_widget_failed.png") { + PreviewShareProfileWidget(state = LinkCard.User.State.NotFound, username = "satoshi") + } + + private fun render(name: String, content: @Composable () -> Unit) { composeRule.mainClock.autoAdvance = false composeRule.setContent { FlipcashTheme { @@ -47,16 +75,7 @@ class ShareProfileWidgetScreenshotTest { .padding(20.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - ShareProfileWidgetBubble( - displayName = "Brad Burnham", - username = "brad_burnham_2", - profilePicture = null, - userId = null, - isFromSelf = false, - position = BubblePosition.Solo, - maxWidth = 290.dp, - onShare = {}, - ) + content() } } } @@ -66,7 +85,7 @@ class ShareProfileWidgetScreenshotTest { val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) root.draw(Canvas(bitmap)) val outDir = File("build/screenshots").apply { mkdirs() } - val file = File(outDir, "share_profile_widget.png") + val file = File(outDir, name) file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") } From dbb7a77fc36ad773cdb0408616df241de6f6c7d7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:23:51 -0400 Subject: [PATCH 4/4] feat(chat): gate widget reply and reactions on the chat's speaker rules A widget stays free of Copy and Report, but Reply and reactions now follow whether the viewer may speak. canSpeak evaluates the speaker rules (never, minimum balance, staff) with the predicate the listener gate already uses. A reply that quotes a widget reads 'Shared a profile'. --- .../app/messenger/internal/ChatViewModel.kt | 53 ++++++++- .../com/flipcash/shared/chat/GroupAccess.kt | 105 ++++++++++++------ .../flipcash/shared/chat/MessageCapability.kt | 35 ++++-- .../flipcash/shared/chat/GroupAccessTest.kt | 46 ++++++++ .../shared/chat/MessageCapabilityTest.kt | 31 +++++- 5 files changed, 220 insertions(+), 50 deletions(-) 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 28edbb36f2..c3596f26d0 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 @@ -64,6 +64,7 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.isDmAddressable @@ -80,6 +81,7 @@ import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.GroupAccess +import com.flipcash.shared.chat.speakerAccess import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.MessagePolicy import com.flipcash.shared.chat.MessageReactions @@ -355,6 +357,11 @@ internal class ChatViewModel @Inject constructor( * and rendering one from a default would blur every contact conversation in the app. */ val groupAccess: GroupAccess? = null, + /** + * Whether the chat's speaker rules let this viewer speak. True for a DM, which has none, + * and until the balance and staff flag arrive, so a group does not flash Reply away. + */ + val canSpeak: Boolean = true, /** * The gate's Join button, same shape as [sendProgress]. Membership arrives from the roster * rather than from the join's own reply, so without this the button would sit unchanged for @@ -524,6 +531,7 @@ internal class ChatViewModel @Inject constructor( /** The gate re-decided, because membership, the rules, or the balance moved. */ data class OnGroupAccessResolved(val access: GroupAccess) : Event + data class OnCanSpeakResolved(val canSpeak: Boolean) : Event /** The gate's "Join Chat" button. */ data object JoinChat : Event @@ -756,6 +764,12 @@ internal class ChatViewModel @Inject constructor( */ private val viewerCanPost = stateFlow.map { !it.isOutsideGroup }.distinctUntilChanged() + /** + * Whether the chat's speaker rules let the viewer speak, alongside [viewerCanPost] (which is + * membership). Only a widget's Reply and reactions read it. + */ + private val viewerCanSpeak = stateFlow.map { it.canSpeak }.distinctUntilChanged() + /** * Live reaction overrides for the open chat — see [ReactionOperations.observeChatReactions]. * A message missing here falls back to `MessageReactions.from(message.reactions)` in @@ -847,8 +861,8 @@ internal class ChatViewModel @Inject constructor( pendingMutations, messagePolicy, senderProfiles, - viewerCanPost, - ) { pagingData, mutations, policy, profiles, canPost -> + combine(viewerCanPost, viewerCanSpeak, ::Pair), + ) { pagingData, mutations, policy, profiles, (canPost, canSpeak) -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.flatMapIndexed { index, content -> @@ -948,7 +962,12 @@ internal class ChatViewModel @Inject constructor( // Resolved once, here, so no menu re-derives it: a later group-role // taxonomy becomes another input to the resolver rather than a branch at // each action site. - capabilities = resolveCapabilities(message, policy, canPost = canPost), + capabilities = resolveCapabilities( + message, + policy, + canPost = canPost, + canSpeak = canSpeak, + ), quote = quote, sender = sender, // Independent of the profile lookup above: the runs have to break by @@ -960,7 +979,7 @@ internal class ChatViewModel @Inject constructor( // `splitAroundLinkCard` keeps these pills on the last row only. reactionPills = storedReactions.pills, selfReactions = storedReactions.selfReactions, - canReact = canReact(message), + canReact = canReact(message, canSpeak = canPost && canSpeak), undecryptableHint = undecryptableHint( encryption = message.encryption, isFromSelf = message.isFromSelf, @@ -1071,6 +1090,15 @@ internal class ChatViewModel @Inject constructor( is MessageContent.Text -> ChatQuoteSnippet.Text(body.text) + // Named the way the chat list previews it, so a citation and its source agree. + is MessageContent.Widget -> ChatQuoteSnippet.Text( + when (body.widget) { + is WidgetContent.ShareProfile -> + resources.getString(R.string.label_chat_preview_sharedProfile) + WidgetContent.Unsupported -> "" + } + ) + // A reply to a reply cites the inner body, not the nested citation. is MessageContent.Reply -> ChatQuoteSnippet.Text( body.content.filterIsInstance() @@ -1627,6 +1655,22 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnGroupAccessResolved(it)) } .launchIn(viewModelScope) + // The speaker rules, on the same terms as the gate above. A DM has none, so it speaks. + stateFlow.map { it.subject as? ChatSubject.Group } + .distinctUntilChanged() + .flatMapLatest { group -> + if (group == null) { + flowOf(true) + } else { + tokenCoordinator.speakerAccess( + rules = group.rules, + isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, + ) + } + } + .onEach { dispatchEvent(Event.OnCanSpeakResolved(it)) } + .launchIn(viewModelScope) + // Observe member identity — if the other member loses identity (e.g. unlinked // their phone), mark the chat as read-only. Gated by chat type through the same rule the // feed filters on, so a tip DM — addressed by user id, named by handle — is never @@ -2675,6 +2719,7 @@ internal class ChatViewModel @Inject constructor( is Event.OnRuleCurrencyResolved -> { state -> state.copy(ruleCurrency = event.currency) } is Event.OnGroupAccessResolved -> { state -> state.copy(groupAccess = event.access) } + is Event.OnCanSpeakResolved -> { state -> state.copy(canSpeak = event.canSpeak) } Event.JoinChat -> { state -> state.copy(joinProgress = LoadingSuccessState(loading = true)) } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt index 7b8f861ba4..0fb8729e5a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt @@ -64,40 +64,7 @@ sealed interface GroupAccess { val listener = rules?.listener.orEmpty() if (listener.isEmpty()) return Eligible - // Keyed by bytes, not by the key object: `class Mint(bytes) : PublicKey(bytes)` - // (libs/encryption/keys/.../Mint.kt), so the `PublicKey`s in `mints` are not `Mint`s and - // a map keyed by `Mint` would miss every one of them. - val byMint: Map, Fiat> = - balances.associate { it.token.address.bytes to it.balance } - - val unmet = listener.firstOrNull { requirement -> - when (requirement) { - is ChatRuleRequirement.MinimumBalance -> { - val held = if (requirement.mints.isEmpty()) { - byMint.values.sum() - } else { - requirement.mints.mapNotNull { byMint[it.bytes] }.maxOrNull() - } - // Compared at display precision, the held side rounded half-up to cents - // (`Fiat.toDouble`), and a total rounded once, after adding: a balance the wallet shows as $5.00 meets a $5 bar even - // when its exact worth is $4.998, and $4.995 passes too. A launchpad - // holding's exact worth depends on the supply this client last saw, and one - // that lags a buy prices the new tokens a fraction below what was paid. The - // server enforces the rule against its own supply, so admitting half a cent - // too generously costs one denied join. iOS's `ConversationGate.unmetBalance` - // rounds the same way; keep the two in step. - held == null || held.toDouble() < requirement.amount.decimalValue - } - // `UserFlags.is_staff` is the same field the rule is written against, and the - // client already has it — so staff are eligible for a staff chat and can rejoin - // one they left. Everyone else is blocked with nothing to buy, which is the arm - // that gets a disabled button rather than a purchase they cannot make. - ChatRuleRequirement.Staff -> !isStaff - // A speaker-only rule; the server does not send it as a listener rule. If one - // ever appears here nobody satisfies it. - ChatRuleRequirement.Never -> true - } - } + val unmet = listener.firstOrNull { it.isUnmet(balances, isStaff) } return if (unmet == null) Eligible else Blocked(unmet) } @@ -129,3 +96,73 @@ fun TokenCoordinator.groupAccess( ) } .distinctUntilChanged() + +/** + * Whether the viewer may speak here: every speaker rule holds, as measured by the same predicate + * the listener gate uses. + * + * Independent of [GroupAccess] on purpose. That type answers "may this viewer read and join", and + * membership settles it before any rule is looked at; speaking is a second question asked of + * someone already in the chat, and a member whose balance has dipped below a speaker bar is still a + * member. [ChatRuleRequirement.Never] is never satisfied, so a chat with a `never` speaker rule is + * read-only for everyone. + * + * Callers combine this with membership themselves: a chat the viewer is outside of has no + * composer to gate, and whether they may post there is already decided by `canPost`. + */ +fun canSpeak( + rules: ChatRules?, + balances: List, + isStaff: Boolean, +): Boolean = rules?.speaker.orEmpty().none { it.isUnmet(balances, isStaff) } + +/** [canSpeak] over the live balance and the live staff flag, with the same de-duplication as [groupAccess]. */ +fun TokenCoordinator.speakerAccess( + rules: ChatRules?, + isStaff: Flow, +): Flow = combine(tokenBalances, isStaff) { balances, staff -> + canSpeak(rules = rules, balances = balances, isStaff = staff) +} + .distinctUntilChanged() + +/** + * Whether one rule is standing in the viewer's way. Shared by the listener gate and [canSpeak] so + * a balance or staff bar means the same thing wherever it is written. + */ +private fun ChatRuleRequirement.isUnmet( + balances: List, + isStaff: Boolean, +): Boolean { + // Keyed by bytes, not by the key object: `class Mint(bytes) : PublicKey(bytes)` + // (libs/encryption/keys/.../Mint.kt), so the `PublicKey`s in `mints` are not `Mint`s and + // a map keyed by `Mint` would miss every one of them. + val byMint: Map, Fiat> = + balances.associate { it.token.address.bytes to it.balance } + + return when (this) { + is ChatRuleRequirement.MinimumBalance -> { + val held = if (mints.isEmpty()) { + byMint.values.sum() + } else { + mints.mapNotNull { byMint[it.bytes] }.maxOrNull() + } + // Compared at display precision, the held side rounded half-up to cents + // (`Fiat.toDouble`), and a total rounded once, after adding: a balance the wallet shows as $5.00 meets a $5 bar even + // when its exact worth is $4.998, and $4.995 passes too. A launchpad + // holding's exact worth depends on the supply this client last saw, and one + // that lags a buy prices the new tokens a fraction below what was paid. The + // server enforces the rule against its own supply, so admitting half a cent + // too generously costs one denied join. iOS's `ConversationGate.unmetBalance` + // rounds the same way; keep the two in step. + held == null || held.toDouble() < amount.decimalValue + } + // `UserFlags.is_staff` is the same field the rule is written against, and the + // client already has it — so staff are eligible for a staff chat and can rejoin + // one they left. Everyone else is blocked with nothing to buy, which is the arm + // that gets a disabled button rather than a purchase they cannot make. + ChatRuleRequirement.Staff -> !isStaff + // Nobody satisfies it. The server sends it as a speaker rule only, where it is what + // makes a chat read-only for everyone; as a listener rule it would lock everyone out. + ChatRuleRequirement.Never -> true + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index fae1e71dc0..4eef219c1e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -103,7 +103,13 @@ data class MessagePolicy( * | Another participant's cash or tip message | Reply, Report | * | A tombstone | none | * | A system notice | none | - * | A widget (server-sent) | none | + * | A widget (server-sent), viewer may speak | Reply | + * | A widget (server-sent), viewer may not speak | none | + * + * A widget is the server's, so it has no text to copy, no sender to report, and is never the + * viewer's to edit or delete. It can still be replied to, which is speaking, so Reply follows the + * chat's speaker rules ([canSpeak]): a `never` rule, an unmet minimum balance or an unmet staff + * requirement takes it away, exactly as it would take away the composer. * * Report follows one rule: anything a participant sent can be reported, and anything the server * wrote, or that no longer exists, cannot. Your own messages are left out because reporting one is @@ -115,14 +121,17 @@ data class MessagePolicy( * * @param canPost whether the viewer may post in this chat. False for a viewer outside a group, * including one who has left it and still has messages of their own in the transcript. + * @param canSpeak whether the chat's speaker rules let the viewer speak (see [canSpeak]). Applied to + * a widget only, the one message whose Reply depends on it. */ fun resolveCapabilities( message: ChatMessage, policy: MessagePolicy = MessagePolicy.Default, now: Instant = Clock.System.now(), canPost: Boolean = true, + canSpeak: Boolean = true, ): Set { - val resolved = resolveForParticipant(message, policy, now) + val resolved = resolveForParticipant(message, policy, now, canSpeak) return if (canPost) resolved else resolved.readOnly() } @@ -138,6 +147,7 @@ private fun resolveForParticipant( message: ChatMessage, policy: MessagePolicy, now: Instant, + canSpeak: Boolean, ): Set { val contents = message.content if (contents.isEmpty()) return emptySet() @@ -160,9 +170,14 @@ private fun resolveForParticipant( } } - // Server-authored notices and widgets, not a participant's message: nothing to copy, reply to - // or report, and no user sender to attribute either to. - if (contents.all { it is MessageContent.System || it is MessageContent.Widget }) return emptySet() + // A system notice is the server's and nothing anyone answers. + if (contents.all { it is MessageContent.System }) return emptySet() + + // A widget is the server's too: nothing to copy, no user sender to report, nothing of the + // viewer's to edit or delete. Replying to it is speaking, so it follows the speaker rules. + if (contents.all { it is MessageContent.Widget }) { + return if (canSpeak) setOf(MessageCapability.Reply) else emptySet() + } val hasText = contents.any { it is MessageContent.Text || it is MessageContent.Reply } @@ -211,18 +226,22 @@ private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean = /** * Whether [message] may be reacted to. * + * [canSpeak] applies to a widget alone, like the same parameter of [resolveCapabilities]. + * * Reactions have no edit/delete-style windows and no report-only carve-out: anyone's message is * reactable, own or another participant's, text or cash. Only two things rule a message out — - * a system notice or widget (nothing a participant sent) and an unconfirmed send (`eventSequence == 0`, + * a system notice (nothing a participant sent) and an unconfirmed send (`eventSequence == 0`, * the same guard [resolveForParticipant] uses: no valid request can name a message the server * has not acknowledged). A tombstone has no content left ([MessageContent.Deleted] clears it), * so the empty-content check below already excludes it. */ -fun canReact(message: ChatMessage): Boolean { +fun canReact(message: ChatMessage, canSpeak: Boolean = true): Boolean { val contents = message.content if (contents.isEmpty()) return false if (contents.any { it is MessageContent.Deleted }) return false if (message.eventSequence == 0L) return false - if (contents.all { it is MessageContent.System || it is MessageContent.Widget }) return false + if (contents.all { it is MessageContent.System }) return false + // A widget is reactable only where the viewer may speak; see [canSpeak]. + if (contents.all { it is MessageContent.Widget }) return canSpeak return true } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt index 4d56f2bb64..16cf1019ad 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt @@ -325,4 +325,50 @@ class GroupAccessTest { assertEquals(GroupAccess.Eligible, access) } + + private fun speaker(vararg speaker: ChatRuleRequirement) = + ChatRules(listener = emptyList(), speaker = speaker.toList()) + + @Test + fun `no speaker rules means the viewer may speak`() { + assertEquals(true, canSpeak(rules = null, balances = emptyList(), isStaff = false)) + assertEquals(true, canSpeak(rules = speaker(), balances = emptyList(), isStaff = false)) + // Listener rules are not speaker rules. + assertEquals( + true, + canSpeak(rules(ChatRuleRequirement.Staff), emptyList(), isStaff = false), + ) + } + + @Test + fun `a never speaker rule silences everyone, staff included`() { + val never = speaker(ChatRuleRequirement.Never) + assertEquals(false, canSpeak(never, listOf(held(1, "BadBoys", 1_000.0)), isStaff = true)) + assertEquals(false, canSpeak(never, emptyList(), isStaff = false)) + } + + @Test + fun `an unmet speaker balance silences the viewer and a met one does not`() { + val bar = speaker(ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys))) + assertEquals(false, canSpeak(bar, listOf(held(1, "BadBoys", 100.0)), isStaff = false)) + assertEquals(true, canSpeak(bar, listOf(held(1, "BadBoys", 600.0)), isStaff = false)) + } + + @Test + fun `a staff speaker rule silences everyone but staff`() { + val staff = speaker(ChatRuleRequirement.Staff) + assertEquals(false, canSpeak(staff, emptyList(), isStaff = false)) + assertEquals(true, canSpeak(staff, emptyList(), isStaff = true)) + } + + @Test + fun `every speaker rule has to hold`() { + val both = speaker( + ChatRuleRequirement.Staff, + ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys)), + ) + assertEquals(false, canSpeak(both, listOf(held(1, "BadBoys", 600.0)), isStaff = false)) + assertEquals(false, canSpeak(both, emptyList(), isStaff = true)) + assertEquals(true, canSpeak(both, listOf(held(1, "BadBoys", 600.0)), isStaff = true)) + } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index d6bfd9a394..bfe456ec50 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -120,11 +120,32 @@ class MessageCapabilityTest { ) assertEquals(emptySet(), resolveCapabilities(theirSystemNotice)) - val widget = message( + } + + private val widgetMessage + get() = message( listOf(MessageContent.Widget(WidgetContent.ShareProfile("brad"))), isFromSelf = false, ) - assertEquals(emptySet(), resolveCapabilities(widget)) + + @Test + fun `a widget offers only Reply where the viewer may speak`() { + // No Copy (no text), no Report (no user sender), no Edit or Delete (not the viewer's). + assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(widgetMessage)) + assertEquals( + setOf(MessageCapability.Reply), + resolveCapabilities(widgetMessage, canPost = true, canSpeak = true), + ) + } + + @Test + fun `a widget offers nothing where the speaker rules forbid speaking`() { + assertEquals(emptySet(), resolveCapabilities(widgetMessage, canSpeak = false)) + } + + @Test + fun `a widget offers nothing to a viewer who cannot post`() { + assertEquals(emptySet(), resolveCapabilities(widgetMessage, canPost = false)) } @Test @@ -386,9 +407,11 @@ class MessageCapabilityTest { } @Test - fun `a widget is not reactable`() { + fun `a widget is reactable where the viewer may speak, and not where they may not`() { val widget = message(listOf(MessageContent.Widget(WidgetContent.ShareProfile("brad")))) - assertEquals(false, canReact(widget)) + assertEquals(true, canReact(widget)) + assertEquals(true, canReact(widget, canSpeak = true)) + assertEquals(false, canReact(widget, canSpeak = false)) } @Test