Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,7 @@
<string name="label_chat_preview_senderMessage">%1$s: %2$s</string>
<string name="label_chat_preview_sentCash_bySender">%1$s sent %2$s</string>
<string name="label_chat_preview_tippedCash_bySender">%1$s tipped %2$s</string>
<string name="label_chat_preview_sharedProfile">Shared a profile</string>
<string name="label_chat_preview_noMessages">Nothing yet</string>
<string name="label_isTyping">Is Typing…</string>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<MessageContent.Text>()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
}
}
}
Expand Down Expand Up @@ -859,7 +877,7 @@ internal object BubbleDefaults {
}

@Composable
private fun Bubble(
internal fun Bubble(
isFromSelf: Boolean,
position: BubblePosition,
maxWidth: Dp,
Expand Down
Loading