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
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ sealed interface ChatStep : FlowStep, Parcelable {

@Parcelize
@Serializable
data class Profile(val contact: ChatParticipant): ChatStep
data class Profile(
val contact: ChatParticipant,
val origin: ProfileOrigin = ProfileOrigin.Chat,
): ChatStep

/**
* The group's own profile — the counterpart of [Profile] for a chat that has no counterparty.
Expand Down Expand Up @@ -138,3 +141,20 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Serializable
data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet
}

/** What opened a [ChatStep.Profile], which decides the actions it offers. */
@Serializable
enum class ProfileOrigin {
/**
* The chat's own title, or a member's picture or reactor row. The chat decides: a tip DM's
* profile is the other person in it, so it offers Mute and not Message.
*/
Chat,

/**
* A tapped `@handle` naming someone other than the DM's counterpart. That person is not who
* the chat is with, so Mute would act on the wrong chat and Message and Send Cash have
* somewhere new to go. iOS's `UserProfileOrigin.mention`.
*/
Mention,
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.chat.ChatSendResult
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.chat.ProfileOrigin
import com.flipcash.app.core.chat.GroupInviteResult
import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.messenger.internal.ChatSubject
Expand Down Expand Up @@ -110,7 +111,7 @@ private fun chatEntryProvider(
}

annotatedEntry<ChatStep.Profile> { step ->
FlowChatProfileScreen(step.contact)
FlowChatProfileScreen(step.contact, step.origin)
}
annotatedEntry<ChatStep.GroupProfile> {
FlowGroupProfileScreen()
Expand Down Expand Up @@ -310,15 +311,15 @@ private fun FlowGroupInviteSheet() {
}

@Composable
private fun FlowChatProfileScreen(participant: ChatParticipant) {
private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileOrigin) {
val viewModel = flowSharedViewModel<ChatProfileViewModel>()
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()

LaunchedEffect(viewModel, participant) {
viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant))
}

ChatProfileScreen(viewModel, flowSharedViewModel<ChatViewModel>())
ChatProfileScreen(viewModel, flowSharedViewModel<ChatViewModel>(), origin)

LaunchedEffect(viewModel) {
viewModel.eventFlow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import com.flipcash.shared.chat.reactions.ReactionStripComposer
import com.flipcash.shared.chat.reactions.SelfReaction
import com.flipcash.shared.chat.readOnly
import com.flipcash.shared.chat.resolveCapabilities
import com.flipcash.shared.chat.ui.detectMentions
import com.flipcash.shared.chat.ui.detectUrls
import com.flipcash.shared.chat.ui.linkableText
import com.flipcash.shared.chat.withinWindows
Expand Down Expand Up @@ -581,6 +582,15 @@ internal class ChatViewModel @Inject constructor(
data object NavigateToInitPayment : Event
data object PresentDepositOptions : Event
data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event

/** The reader tapped a mention of [username] in the transcript. */
data class MentionTapped(val username: String) : Event

/**
* Where a tapped mention resolved to, for the screen to open. Only the destinations that
* open something: an unclaimed handle or a failed lookup is a dialog raised here.
*/
data class OpenMention(val destination: MentionDestination) : Event
data object OnConfirmRequested : Event

/** Confirm the DM-opening fee. The amount comes from the fee, not from the keypad. */
Expand Down Expand Up @@ -850,8 +860,13 @@ internal class ChatViewModel @Inject constructor(
// which meant a chat painted nothing until every link in the first window had
// been round-tripped, and that every link in every mapped message was queried
// whether or not the reader ever scrolled to it.
val linkCard = enriched.linkableText()
?.let { text -> linkCardClassifier.firstCard(detectUrls(text)) }
val linkableText = enriched.linkableText()
val links = linkableText?.let(::detectUrls).orEmpty()
val linkCard = linkCardClassifier.firstCard(links)

// Detected over the whole text, beside the links, so a handle inside a link
// stays the link's. Nothing is looked up until one is tapped.
val mentions = linkableText?.let { detectMentions(it, links) }.orEmpty()

// Noted while the voucher and the message it came on are in the same hand;
// see [ClaimReplyTargets]. Skipped for the reader's own messages, which is what
Expand Down Expand Up @@ -917,6 +932,7 @@ internal class ChatViewModel @Inject constructor(
// after the first page does.
senderId = message.senderId?.takeIf { !message.isFromSelf },
linkCard = linkCard,
mentions = mentions,
// `splitAroundLinkCard` keeps these pills on the last row only.
reactionPills = storedReactions.pills,
selfReactions = storedReactions.selfReactions,
Expand Down Expand Up @@ -1152,6 +1168,7 @@ internal class ChatViewModel @Inject constructor(
initChatHandlers()
initLinkCardFreshness()
initClaimReplies()
initMentionHandlers()
initDraftHandlers()
initGroupAnalytics()

Expand Down Expand Up @@ -1248,6 +1265,33 @@ internal class ChatViewModel @Inject constructor(
.launchIn(viewModelScope)
}

/**
* Opens where a tapped `@handle` leads. Looked up through [linkCardResolver], so a handle a
* person card or an earlier tap already resolved opens without a round trip.
*
* An unclaimed handle and a failed lookup get different dialogs: one is an answer, the other
* is worth trying again.
*/
private fun initMentionHandlers() {
eventFlow.filterIsInstance<Event.MentionTapped>()
.onEach { event ->
val lookup = linkCardResolver.lookUpUser(LinkCard.User.Identity.ByUsername(event.username))
val counterpart = (stateFlow.value.participant as? ChatParticipant.TipUser)?.userId
when (val destination = mentionDestination(event.username, lookup, counterpart)) {
is MentionDestination.NoSuchAccount -> BottomBarManager.showInfo(
title = resources.getString(R.string.error_title_usernameNotFound),
message = resources.getString(R.string.error_description_usernameNotFound, destination.username),
)
MentionDestination.LookupFailed -> BottomBarManager.showError(
title = resources.getString(R.string.error_title_profileUnavailable),
message = resources.getString(R.string.error_description_profileUnavailable),
)
else -> dispatchEvent(Event.OpenMention(destination))
}
}
.launchIn(viewModelScope)
}

/**
* Replies to the voucher with a thank-you, once a link tapped here has actually been collected.
*
Expand Down Expand Up @@ -2636,6 +2680,8 @@ internal class ChatViewModel @Inject constructor(
Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
is Event.PresentDepositOptions -> { state -> state }
is Event.OpenScreen -> { state -> state }
is Event.MentionTapped -> { state -> state }
is Event.OpenMention -> { state -> state }
is Event.OnConfirmRequested -> { state -> state }
is Event.OnInitPaymentConfirmed -> { state -> state }
is Event.OnSendRequested -> { state -> state }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.flipcash.app.messenger.internal

import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.chat.ProfileOrigin
import com.flipcash.app.messenger.internal.link.UserLinkLookup
import com.flipcash.services.models.GetUserProfileError
import com.flipcash.shared.chat.models.LinkCard
import com.getcode.opencode.model.core.ID

/** Where a tapped `@handle` lands, once the handle has been looked up. iOS's `MentionDestination`. */
internal sealed interface MentionDestination {
/** The handle is the viewer's own. Matches a person card linking to the viewer. */
data object OwnTipCard : MentionDestination

data class Profile(
val participant: ChatParticipant.TipUser,
val origin: ProfileOrigin,
) : MentionDestination

/** Nobody has claimed [username]. */
data class NoSuchAccount(val username: String) : MentionDestination

/** The lookup never got an answer. */
data object LookupFailed : MentionDestination
}

/**
* Where a mention of [username] lands, given its [lookup] and the person the open DM is with, if
* any.
*
* The counterpart opens the way the DM's own title does, with Mute and without Message, since
* Message would lead straight back here. Anyone else gets the shortcuts and no Mute.
*/
internal fun mentionDestination(
username: String,
lookup: Result<LinkCard.User.State.Resolved>,
counterpart: ID?,
): MentionDestination = lookup.fold(
onSuccess = { found ->
if (found.isOwn) {
MentionDestination.OwnTipCard
} else {
MentionDestination.Profile(
participant = ChatParticipant.TipUser(found.userId, found.profile),
origin = if (found.userId == counterpart) ProfileOrigin.Chat else ProfileOrigin.Mention,
)
}
},
onFailure = { error ->
if (error.meansUnclaimed()) {
MentionDestination.NoSuchAccount(username)
} else {
MentionDestination.LookupFailed
}
},
)

// The server answers an unclaimed handle with an id-less profile, which the lookup fails as
// `NoSuchAccount`; `NotFound` is the same answer if it ever comes back as an error instead.
private fun Throwable.meansUnclaimed(): Boolean =
this is UserLinkLookup.NoSuchAccount || this is GetUserProfileError.NotFound
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,22 @@ internal class LinkCardResolver(
is LinkCard.User -> card.copy(state = userState(card.identity))
}

/**
* The person [identity] names, for a tapped `@handle`: the same lookup and the same stored
* answers a person card for `flipcash.com/<handle>` uses, so a handle a card or an earlier tap
* already resolved opens without a round trip.
*
* Unlike [resolve], the failure comes back whole. A card draws an unclaimed handle and a
* dropped connection alike, but a tap has to tell the reader which one happened. A stored
* answer does not outlive the server saying the handle names nobody.
*/
suspend fun lookUpUser(identity: LinkCard.User.Identity): Result<LinkCard.User.State.Resolved> {
userAnswers[identity]?.let { return Result.success(it) }
return user(identity)
.onSuccess { memory.putUser(identity, it) }
.onFailure { if (it.meansLinkIsGone()) memory.removeUser(identity) }
}

/**
* Drops what is held about [entropy] and tells the cards on screen to ask again.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -36,6 +37,7 @@ import com.flipcash.app.core.links.ExternalLinkUriHandler
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.MentionDestination
import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
Expand All @@ -50,6 +52,7 @@ import com.getcode.ui.theme.ScaffoldBarPlacement
import com.getcode.ui.utils.rememberKeyboardController
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.flow.filterIsInstance

@Composable
internal fun MessengerScreen(viewModel: ChatViewModel) {
Expand All @@ -71,6 +74,27 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
var barHeight by remember { mutableStateOf(0.dp) }
val keyboard = rememberKeyboardController()

// Here rather than in the action handler because the lookup comes first: the tap only knows a
// handle, and where it leads is the view model's to decide once the handle has an answer.
LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<ChatViewModel.Event.OpenMention>()
.collect { (destination) ->
keyboard.hideIfVisible {
when (destination) {
// As a person card linking to the viewer does: their card is on the You tab.
MentionDestination.OwnTipCard ->
navigator.rootNavigator.navigateAll(listOf(AppRoute.Tabs.Menu))
is MentionDestination.Profile ->
navigator.push(ChatStep.Profile(destination.participant, destination.origin))
// Raised as dialogs by the view model; they never reach here.
is MentionDestination.NoSuchAccount,
MentionDestination.LookupFailed -> Unit
}
}
}
}

val chatActionHandler = { action: ChatAction ->
when (action) {
is ChatAction.AdvanceReadPointer -> {
Expand Down Expand Up @@ -159,6 +183,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}

is ChatAction.OpenMention -> {
viewModel.dispatchEvent(ChatViewModel.Event.MentionTapped(action.username))
}

is ChatAction.ToggleSelection -> {
viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.chat.ProfileOrigin
import com.flipcash.app.core.chat.ReportSubject
import com.flipcash.app.core.AppRoute
import com.getcode.navigation.core.LocalCodeNavigator
Expand Down Expand Up @@ -66,18 +67,24 @@ import kotlin.time.Instant
* The Message and Send Cash shortcuts follow the same split, the other way round: they show on a
* group member's profile and not on a tip DM's, where they would only reopen the chat behind it.
* [profileShortcutRecipient] has the whole rule. Share shows on every person's profile.
*
* A tapped `@handle` naming someone other than a tip DM's counterpart opens with
* [ProfileOrigin.Mention]. That person is not who the chat is with, so the screen leaves the chat
* out, as a `flipcash.com` link's profile does: no Mute, and Message shows.
*/
@Composable
internal fun ChatProfileScreen(
viewModel: ChatProfileViewModel,
chatViewModel: ChatViewModel,
origin: ProfileOrigin = ProfileOrigin.Chat,
) {
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()
val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle()

PersonProfileScreen(
viewModel = viewModel,
chat = ProfileChat(
chat = profileChat(
origin = origin,
chatId = chatState.chatId,
chatType = chatState.chatType,
viewerState = chatState.viewerState,
Expand All @@ -87,6 +94,20 @@ internal fun ChatProfileScreen(
)
}

/**
* The chat behind a profile opened from inside it, or null for a mention, whose person the chat is
* not with.
*/
internal fun profileChat(
origin: ProfileOrigin,
chatId: ChatId?,
chatType: ChatType,
viewerState: ViewerState?,
): ProfileChat? = when (origin) {
ProfileOrigin.Chat -> ProfileChat(chatId, chatType, viewerState)
ProfileOrigin.Mention -> null
}

/**
* The conversation a person's profile was opened from, for the parts of the screen that act on the
* chat rather than the person: the mute row and the muted chip.
Expand Down
Loading
Loading