diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 869d0d3066..c162722169 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -973,6 +973,7 @@ %1$s: %2$s %1$s sent %2$s %1$s tipped %2$s + Shared a profile Nothing yet Is Typing… 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/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..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 @@ -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,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId)) } + // 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) 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..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 @@ -44,6 +44,16 @@ sealed interface ChatAction { * the tap, and the handler decides where it leads once it has. */ data class OpenMention(val username: String) : ChatAction + + /** + * 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 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/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 5282068175..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 @@ -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,11 @@ 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 -> 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..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 @@ -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,25 @@ 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 else null, + onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, + attention = attention, + ) + + WidgetContent.Unsupported -> UndecryptableBubble( + modifier = modifier, + hint = UndecryptableHint.UpdateApp, + ) + } } } } @@ -859,7 +877,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..b7d31ce219 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt @@ -0,0 +1,328 @@ +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.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 +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.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 +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. 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. + * + * 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( + username: String, + isFromSelf: Boolean, + position: BubblePosition, + maxWidth: Dp, + onShare: ((ChatAction.ShareProfile) -> Unit)?, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + attention: () -> Float = { 0f }, +) { + val profile = rememberWidgetProfile(username) + ShareProfileWidgetBubble( + displayName = (profile as? WidgetProfile.Resolved)?.displayName ?: username, + username = username, + profilePicture = (profile as? WidgetProfile.Resolved)?.picture, + userId = (profile as? WidgetProfile.Resolved)?.userId, + loading = profile is WidgetProfile.Loading, + isFromSelf = isFromSelf, + position = position, + maxWidth = maxWidth, + 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, + attention = attention, + ) +} + +/** 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( + displayName: String, + username: String, + profilePicture: MediaItem?, + userId: ID?, + isFromSelf: Boolean, + position: BubblePosition, + maxWidth: Dp, + onShare: (() -> Unit)?, + loading: Boolean = false, + 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, + modifier = if (loading) Modifier.shimmer(RoundedCornerShape(4.dp)) else Modifier, + ) + 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) +} + +/** 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", + profilePicture = null, + userId = null, + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = 320.dp, + onShare = {}, + 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/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 4f91601961..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 @@ -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,20 @@ class ChatSummaryPreviewTest { assertTrue(reference.hasMessages) assertNull(reference.lastMessagePreview) } + + @Test + fun `a shared profile previews the same whoever the sender is`() { + every { + resources.getString(R.string.label_chat_preview_sharedProfile) + } returns "Shared a profile" + val widget = MessageContent.Widget(WidgetContent.ShareProfile("brad")) + + assertEquals("Shared a profile", preview(listOf(widget))) + assertEquals("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..5138f08cd4 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt @@ -0,0 +1,92 @@ +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.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 +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 10588:1979). Not an assertion test — it writes to + * `build/screenshots/`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w360dp-h340dp-xxhdpi") +class ShareProfileWidgetScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + 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 { + Column( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + content() + } + } + } + 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, name) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} 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 fcad28120c..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,6 +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), 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 @@ -114,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() } @@ -137,6 +147,7 @@ private fun resolveForParticipant( message: ChatMessage, policy: MessagePolicy, now: Instant, + canSpeak: Boolean, ): Set { val contents = message.content if (contents.isEmpty()) return emptySet() @@ -159,9 +170,15 @@ private fun resolveForParticipant( } } - // Server-authored notices, not a participant's message. + // 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 } return buildSet { @@ -209,6 +226,8 @@ 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 (nothing a participant sent) and an unconfirmed send (`eventSequence == 0`, @@ -216,11 +235,13 @@ private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean = * 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 }) 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 aa51bc0cd3..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 @@ -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,33 @@ class MessageCapabilityTest { isFromSelf = false, ) assertEquals(emptySet(), resolveCapabilities(theirSystemNotice)) + + } + + private val widgetMessage + get() = message( + listOf(MessageContent.Widget(WidgetContent.ShareProfile("brad"))), + isFromSelf = false, + ) + + @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 @@ -378,6 +406,14 @@ class MessageCapabilityTest { assertEquals(false, canReact(system)) } + @Test + 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(true, canReact(widget)) + assertEquals(true, canReact(widget, canSpeak = true)) + assertEquals(false, canReact(widget, canSpeak = false)) + } + @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