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