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
8 changes: 6 additions & 2 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -892,8 +892,8 @@

<string name="label_fromYourContacts">From Your Contacts</string>
<string name="label_addContact">Add Contact</string>
<string name="label_chat_balanceRequirement">Balance Requirement: %1$s of %2$s</string>
<string name="label_chat_balanceRequirement_anyToken">Balance Requirement: %1$s</string>
<string name="label_chat_balanceRequirement">Balance Requirement:\n%1$s of %2$s</string>
<string name="label_chat_balanceRequirement_anyToken">Balance Requirement:\n%1$s</string>
<string name="label_chat_staffRequirement">Flipcash Staff Only</string>
<string name="subtitle_chatGate_minimumBalance">Minimum Balance: %1$s of %2$s</string>
<string name="subtitle_chatGate_minimumBalance_anyToken">Minimum Balance: %1$s</string>
Expand Down Expand Up @@ -965,6 +965,10 @@
<string name="label_linkCard_claim">Tap to claim</string>
<string name="label_linkCard_claimed">Claimed</string>
<string name="label_linkCard_expired">Expired</string>
<!-- Group invite link card. The untitled name is provisional, pending copy. -->
<string name="label_linkCard_untitledGroup">Group Chat</string>
<string name="action_linkCard_startChatting">Start Chatting</string>
<string name="label_linkCard_groupUnavailable">Group Unavailable</string>

<!-- Chat message selection bar and its actions -->
<string name="action_copy">Copy</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import com.flipcash.shared.chat.models.ChatQuoteSnippet
import com.flipcash.shared.chat.models.LinkCard
import com.flipcash.shared.chat.models.LinkCardResolution
import com.flipcash.shared.chat.models.ReceiptStatus
import com.flipcash.shared.chat.models.splitAroundLinkCard
import com.flipcash.shared.chat.models.SenderIdentity
import com.flipcash.shared.chat.models.SeparatorConfig
import com.flipcash.shared.chat.resolveCapabilities
Expand Down Expand Up @@ -589,7 +590,7 @@ internal class ChatViewModel @Inject constructor(
) { pagingData, mutations, policy, profiles ->
pagingData.flatMap { stored ->
val message = stored.applying(mutations[stored.messageId])
message.content.mapIndexed { index, content ->
message.content.flatMapIndexed { index, content ->
val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) {
val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token
if (token != null) {
Expand Down Expand Up @@ -679,6 +680,11 @@ internal class ChatViewModel @Inject constructor(
senderId = message.senderId?.takeIf { !message.isFromSelf },
linkCard = linkCard,
)
// A carded link takes a row of its own, with the prose either side of it
// on rows above and below. Reversed because the list is: this page runs
// newest-first under reverseLayout, so the row drawn lowest goes first.
.splitAroundLinkCard()
.asReversed()
}
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
if (before == null || after == null) return@insertSeparators null
Expand Down Expand Up @@ -2145,7 +2151,9 @@ internal class ChatViewModel @Inject constructor(
is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) }
is Event.MessagePolicyChanged -> { state -> state.copy(messagePolicy = event.policy) }
is Event.ToggleMessageSelection -> { state ->
val alreadySelected = state.selection?.itemKey == event.bubble.itemKey
// By message rather than by row: a message split around its card is one
// selection whichever of its rows was pressed.
val alreadySelected = state.selection?.messageKey == event.bubble.messageKey
// The transcript resolved this bubble when it was mapped, which may have been
// well inside a window that has since closed. Narrow it again here so the bar
// offers what is open now rather than what was open when the row was built.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.flipcash.app.messenger.internal.link

import com.flipcash.app.core.tokens.brandedName
import com.flipcash.app.core.tokens.isReserve
import com.flipcash.app.messenger.internal.balanceRequirement
import com.flipcash.app.messenger.internal.requiresStaff
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.ViewMode
import com.flipcash.shared.chat.models.LinkCard
import com.getcode.solana.keys.Mint
import com.getcode.util.resources.ResourceHelper
import javax.inject.Inject

/**
* Reads a group's public record for an invite card.
*
* `GetChat` in the redacted view answers for any registered user, member or not, which is what an
* invite needs: most people reading one are not in the group yet. The card is built from four
* fields and no others -- title, picture, roster summary, rules. [ChatMetadata.members] is never
* read here, so no member name or avatar can reach the card, including as a fallback title.
*
* Anything that is not a group -- a DM id pasted into a `/chat/` link, or a chat that has gone --
* fails the lookup, and the card draws its unavailable state.
*
* The requirement is stated the way the chat's own head card states it, including the reserve
* being named by the amount alone ("$100", not "$100 of Dollars"). An unknown mint is not a
* failure: the amount is still true, so it is stated without a token beside it.
*/
internal class GroupLinkLookup @Inject constructor(
private val chatController: ChatController,
private val tokenCoordinator: TokenCoordinator,
private val resources: ResourceHelper,
) {
suspend operator fun invoke(chatId: ChatId): Result<LinkCard.GroupInvite.State.Resolved> =
runCatching {
val chat = chatController.getChat(chatId, ViewMode.REDACTED).getOrThrow()
require(chat.type == ChatType.GROUP) { "chat $chatId is not a group" }

val balance = chat.rules.balanceRequirement()
val staffOnly = chat.rules.requiresStaff()
val requirement = if (balance != null || staffOnly) {
val token = balance?.mints?.firstOrNull()?.let { mint ->
tokenCoordinator.getTokenMetadata(Mint(mint.bytes)).getOrNull()?.token
}
LinkCard.GroupInvite.Requirement(
amount = balance?.amount?.formatted(),
currencyName = token?.takeUnless { it.isReserve }?.brandedName(resources),
staffOnly = staffOnly,
)
} else {
null
}

LinkCard.GroupInvite.State.Resolved(
title = chat.title?.takeIf { it.isNotBlank() },
picture = chat.picture,
memberCount = chat.rosterSummary.memberCount,
requirement = requirement,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ internal class LinkCardClassifier @Inject constructor(
mint = type.mint,
state = LinkCard.TokenInfo.State.Loading,
)
// A group's invite, and only the bare `/chat/{uuid}`. The router claims anything under
// it, so the send-cash path beneath (`/chat/{uuid}/send`, iOS's `chatSendCash`) is
// refused here by shape: it opens a payment, not the group. `TipChat` falls to `else`.
is DeeplinkType.GroupChatInvite -> LinkCard.GroupInvite(
url = target,
start = link.start,
end = link.end,
chatId = type.chatId,
state = LinkCard.GroupInvite.State.Loading,
).takeIf { target.toUri().pathSegments.size == GROUP_INVITE_SEGMENTS }
else -> null
}
}
Expand Down Expand Up @@ -105,6 +115,9 @@ internal class LinkCardClassifier @Inject constructor(
}

companion object {
/** `chat`, then the id. */
private const val GROUP_INVITE_SEGMENTS = 2

private const val JUMP_HOST = "jump.flipcash.com"
private const val JUMP_SOURCE_PARAM = "source="

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal object LinkCardModule {
fun provideLinkCardResolver(
giftCard: GiftCardLookup,
token: TokenLinkLookup,
group: GroupLinkLookup,
): LinkCardResolver =
LinkCardResolver(
// The resolver's own scope, ended by `ChatViewModel.onCleared`. A query outlives the
Expand All @@ -37,5 +38,6 @@ internal object LinkCardModule {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
giftCard = { giftCard(it) },
tokenMetadata = { token(it) },
group = { group(it) },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link

import com.flipcash.shared.chat.models.LinkCard
import com.flipcash.shared.chat.models.LinkCardResolution
import com.flipcash.services.models.chat.ChatId
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import kotlinx.coroutines.CoroutineScope
Expand All @@ -25,7 +26,8 @@ import java.util.concurrent.ConcurrentHashMap
* hard stop. Nothing on this path may reach `BillController.receiveGiftCard`, because that claims
* the link, and a card that claimed what it rendered would empty a link by scrolling past it.
*
* A token link needs only the mint's metadata, which the wallet already caches.
* A token link needs only the mint's metadata, which the wallet already caches. A group invite
* needs the group's public record, read in the redacted view -- see [GroupLinkLookup].
*
* The query runs in [scope] rather than in the caller's coroutine. The caller is a card that has
* just been composed, and it is cancelled by an ordinary scroll. A query awaited inline dies with
Expand All @@ -42,6 +44,7 @@ internal class LinkCardResolver(
private val scope: CoroutineScope,
private val giftCard: suspend (entropy: String) -> Result<Snapshot>,
private val tokenMetadata: suspend (mint: Mint) -> Result<Token>,
private val group: suspend (chatId: ChatId) -> Result<LinkCard.GroupInvite.State.Resolved>,
) : LinkCardResolution {

data class Snapshot(
Expand All @@ -59,6 +62,7 @@ internal class LinkCardResolver(
*/
private val cashQueries = mutableMapOf<String, Deferred<LinkCard.Cash.State>>()
private val tokenQueries = mutableMapOf<Mint, Deferred<LinkCard.TokenInfo.State>>()
private val groupQueries = mutableMapOf<ChatId, Deferred<LinkCard.GroupInvite.State>>()

/**
* The answers that have landed, readable without the lock and without suspending — which is the
Expand All @@ -75,18 +79,21 @@ internal class LinkCardResolver(
*/
private val cashAnswers = ConcurrentHashMap<String, LinkCard.Cash.State.Resolved>()
private val tokenAnswers = ConcurrentHashMap<Mint, LinkCard.TokenInfo.State.Resolved>()
private val groupAnswers = ConcurrentHashMap<ChatId, LinkCard.GroupInvite.State.Resolved>()

private val _revision = MutableStateFlow(0)
override val revision: StateFlow<Int> = _revision.asStateFlow()

override fun peek(card: LinkCard): LinkCard? = when (card) {
is LinkCard.Cash -> cashAnswers[card.entropy]?.let { card.copy(state = it) }
is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) }
is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) }
}

override suspend fun resolve(card: LinkCard): LinkCard = when (card) {
is LinkCard.Cash -> card.copy(state = cashState(card.entropy))
is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint))
is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId))
}

/**
Expand Down Expand Up @@ -163,6 +170,14 @@ internal class LinkCardResolver(
)
}

private suspend fun groupState(chatId: ChatId): LinkCard.GroupInvite.State =
memoized(groupQueries, chatId) {
group(chatId).fold(
onSuccess = { resolved -> resolved.also { groupAnswers[chatId] = it } },
onFailure = { forget(groupQueries, chatId); LinkCard.GroupInvite.State.Unavailable },
)
}

private suspend fun <K, V> memoized(
queries: MutableMap<K, Deferred<V>>,
key: K,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
Expand Down Expand Up @@ -78,6 +79,21 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}

is ChatAction.OpenGroup -> {
// An invite to the chat already on screen has nowhere to go.
if (action.chatId != state.chatId) {
keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.OpenScreen(
// Pushed, so Back returns to this chat. The pushed screen gates
// itself: a non-member sees the join or the buy there.
AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(action.chatId))
)
)
}
}
}

is ChatAction.ToggleSelection -> {
viewModel.dispatchEvent(
ChatViewModel.Event.ToggleMessageSelection(action.bubble)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ internal fun LazyListLayoutInfo.headroomAbove(
messages: LazyPagingItems<ChatListItem>,
messageId: Long,
): Int {
val row = rowFor(messages, messageId) ?: return 0
// The topmost row of the message, which is the one that can be under the bar: a message split
// around its card is several rows, and rowFor answers with the lowest.
val row = visibleItemsInfo.lastOrNull { info ->
info.index < messages.itemCount &&
(messages.peek(info.index) as? ChatListItem.ContentBubble)?.messageId == messageId
} ?: return 0
return viewportEndOffset - afterContentPadding - (row.offset + row.size)
}
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ internal fun MessageList(
// half-clipped where the sheet cuts across it.
state.confirmingDelete -> false
state.editing != null -> bubble?.messageId == state.editing.messageId
state.selection != null -> bubble?.itemKey == state.selection.itemKey
// Every row of the message stays sharp, so a split message is focused whole.
state.selection != null -> bubble?.messageKey == state.selection.messageKey
else -> true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import com.flipcash.shared.chat.models.ReceiptStatus
import com.flipcash.shared.chat.models.SeparatorConfig
import com.flipcash.features.messenger.R
import com.flipcash.shared.chat.ui.ContentBubble
import com.flipcash.shared.chat.ui.rendersBareEmoji
import com.flipcash.shared.chat.ui.rendersBare
import com.flipcash.shared.common.ui.ContactAvatar
import com.flipcash.shared.chat.ui.bubblePositionOf
import com.getcode.theme.CodeTheme
Expand Down Expand Up @@ -310,13 +310,14 @@ internal fun MessageRow(
}
val showReceipt =
shouldShowReceiptLabel(index, item, messages, otherReadPointer)
// An emoji-only message has no bubble, so its "Edited" marker has nowhere
// to sit inside the message and comes out here instead — on the same line
// as the receipt, and ahead of it, so the two read as one trailing note.
// It stands alone on rows that carry no receipt, which is every incoming
// one.
// An emoji-only message, or a card row, has no bubble, so its "Edited"
// marker has nowhere to sit inside the message and comes out here
// instead — on the same line as the receipt, and ahead of it, so the two
// read as one trailing note. It stands alone on rows that carry no
// receipt, which is every incoming one. A split message marks only its
// last row.
Row(verticalAlignment = Alignment.Top) {
if (item.isEdited && item.rendersBareEmoji()) {
if (item.isEdited && item.isLastRow && item.rendersBare()) {
Text(
modifier = Modifier.padding(
top = CodeTheme.dimens.grid.x1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ internal fun shouldShowReceiptLabel(
otherReadPointer: MessagePointer?,
): Boolean {
if (!item.carriesReceipt) return false
// A split message's receipt goes under its last row only. The rows above share its status, so
// the rules below would already hide them -- except a failure, which shows everywhere it can.
if (!item.isLastRow) return false
val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false
if (status == ReceiptStatus.FAILED) return true
if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false
Expand Down
Loading
Loading