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
@@ -0,0 +1,16 @@
package com.flipcash.app.messenger.internal

import com.flipcash.features.messenger.R
import com.getcode.util.DateUtils
import com.getcode.util.resources.ResourceHelper
import kotlin.time.Instant

/**
* "Joined March 2024": how a person's join date reads, on their profile and on a link to them.
* One function so the two surfaces cannot drift apart in wording or format.
*/
internal fun joinedLine(joinedAt: Instant, resources: ResourceHelper): String =
resources.getString(
R.string.subtitle_joinedDate,
DateUtils.getDate(joinedAt.toEpochMilliseconds(), "MMMM yyyy"),
)
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import javax.inject.Inject
* problem should not change where a tapped link goes.
*
* **Route.** Whatever survives goes to the real classifier, which holds the reserved-path list
* that keeps `flipcash.com/download` a plain link. No second path parser is written.
* that keeps `flipcash.com/download` a plain link. No second path parser is written. Five routes
* become cards: a cash link, a token, a group invite, and a person's link by id or by handle.
*
* `/login` and `/verify` are excluded by omission — they carry the account seed and a
* verification secret, and a card with a tap target in front of either is a phishing aid.
Expand Down Expand Up @@ -82,6 +83,24 @@ internal class LinkCardClassifier @Inject constructor(
chatId = type.chatId,
state = LinkCard.GroupInvite.State.Loading,
).takeIf { target.toUri().pathSegments.size == GROUP_INVITE_SEGMENTS }
// A person's link, by id: the no-handle `flipcash.com/<uuid>`, or the legacy
// `/tip/<uuid>`. Whether anyone owns the id is the lookup's question.
is DeeplinkType.Tipcard -> LinkCard.User(
url = target,
start = link.start,
end = link.end,
identity = LinkCard.User.Identity.ById(type.userId),
state = LinkCard.User.State.Loading,
)
// By handle. The router has already refused the website's own pages
// (`AppRouter.reservedProfilePaths`), which is what keeps `/download` a plain link.
is DeeplinkType.TipcardByUsername -> LinkCard.User(
url = target,
start = link.start,
end = link.end,
identity = LinkCard.User.Identity.ByUsername(type.username),
state = LinkCard.User.State.Loading,
)
else -> null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal object LinkCardModule {
giftCard: GiftCardLookup,
token: TokenLinkLookup,
group: GroupLinkLookup,
user: UserLinkLookup,
): LinkCardResolver =
LinkCardResolver(
// The resolver's own scope, ended by `ChatViewModel.onCleared`. A query outlives the
Expand All @@ -39,5 +40,6 @@ internal object LinkCardModule {
giftCard = { giftCard(it) },
tokenMetadata = { token(it) },
group = { group(it) },
user = { user(it) },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import java.util.concurrent.ConcurrentHashMap
* 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 group invite
* needs the group's public record, read in the redacted view -- see [GroupLinkLookup].
* needs the group's public record, read in the redacted view -- see [GroupLinkLookup]. A person's
* link needs their public profile, fetched by id or handle -- see [UserLinkLookup].
*
* 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 @@ -45,6 +46,7 @@ internal class LinkCardResolver(
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>,
private val user: suspend (identity: LinkCard.User.Identity) -> Result<LinkCard.User.State.Resolved>,
) : LinkCardResolution {

data class Snapshot(
Expand All @@ -63,6 +65,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>>()
private val userQueries = mutableMapOf<LinkCard.User.Identity, Deferred<LinkCard.User.State>>()

/**
* The answers that have landed, readable without the lock and without suspending — which is the
Expand All @@ -80,6 +83,7 @@ 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 userAnswers = ConcurrentHashMap<LinkCard.User.Identity, LinkCard.User.State.Resolved>()

private val _revision = MutableStateFlow(0)
override val revision: StateFlow<Int> = _revision.asStateFlow()
Expand All @@ -88,12 +92,14 @@ internal class LinkCardResolver(
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) }
is LinkCard.User -> userAnswers[card.identity]?.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))
is LinkCard.User -> card.copy(state = userState(card.identity))
}

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

private suspend fun userState(identity: LinkCard.User.Identity): LinkCard.User.State =
memoized(userQueries, identity) {
user(identity).fold(
onSuccess = { resolved -> resolved.also { userAnswers[identity] = it } },
onFailure = { forget(userQueries, identity); LinkCard.User.State.NotFound },
)
}

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
@@ -0,0 +1,69 @@
package com.flipcash.app.messenger.internal.link

import com.flipcash.app.messenger.internal.joinedLine
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.handle
import com.flipcash.services.models.nameOrHandle
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.models.LinkCard
import com.getcode.opencode.model.core.ID
import com.getcode.util.resources.ResourceHelper
import javax.inject.Inject

/**
* Reads a person's public profile for a link card.
*
* A plain profile fetch, by id or by handle, and nothing else. The tip flow resolves the same
* links, but it caches the profile, raises the tip bill and navigates, and a card that did any of
* that would act on a link by scrolling past it.
*
* An unclaimed handle does not fail: the server answers with an empty profile. With no id in it
* there is no account to show, so that is a failure here, the same as a transport error. Either
* way the card draws its not-found state, and the resolver forgets the answer so a later
* appearance asks again.
*/
internal class UserLinkLookup @Inject constructor(
private val profiles: ProfileController,
private val userManager: UserManager,
private val resources: ResourceHelper,
) {
suspend operator fun invoke(identity: LinkCard.User.Identity): Result<LinkCard.User.State.Resolved> =
runCatching {
val profile = when (identity) {
is LinkCard.User.Identity.ById -> profiles.getProfileForUser(identity.userId)
is LinkCard.User.Identity.ByUsername -> profiles.getProfileForUsername(identity.username)
}.getOrThrow()
userCardState(
profile = profile,
viewerId = userManager.accountId,
joined = { joinedLine(it, resources) },
) ?: throw NoSuchAccount(identity)
}

class NoSuchAccount(identity: LinkCard.User.Identity) :
IllegalStateException("no account behind $identity")
}

/**
* What a fetched profile says on the card, or null when it names no account.
*
* Only the profile's own fields are read: name, handle, join date and picture. The name falls back
* to the handle, and the handle line is then left out rather than saying the same thing twice.
*/
internal fun userCardState(
profile: UserProfile,
viewerId: ID?,
joined: (kotlin.time.Instant) -> String,
): LinkCard.User.State.Resolved? {
val userId = profile.userId ?: return null
val displayName = profile.displayName.takeIf { it.isNotBlank() }
return LinkCard.User.State.Resolved(
userId = userId,
isOwn = userId == viewerId,
profile = profile,
name = nameOrHandle(displayName, profile.handle),
handle = profile.handle.takeIf { displayName != null },
joined = profile.joinedAt?.let(joined),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import com.flipcash.analytics.GroupGateFunding
import com.flipcash.analytics.GroupInviteSheetSource
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.links.ExternalLinkUriHandler
import com.flipcash.app.core.tokens.TokenInfoEntry
Expand Down Expand Up @@ -125,6 +127,29 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}

is ChatAction.OpenUser -> {
val counterpart = (state.participant as? ChatParticipant.TipUser)?.userId
when {
// Your own card lives on the You tab, where scanning it and its deep link
// already send you. There is no chat with yourself to open.
action.isOwn -> keyboard.hideIfVisible {
navigator.rootNavigator.navigateAll(listOf(AppRoute.Tabs.Menu))
}
// A link to the person this DM is with has nowhere to go.
action.userId == counterpart -> Unit
else -> keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.OpenScreen(
// The DM the tip flow ends at, pushed as the group card pushes
// its chat, so Back returns here. Its own fee sheet handles any
// fee to message them.
AppRoute.Messaging.Chat(ChatIdentifier.ByUser(action.userId, action.profile))
)
)
}
}
}

is ChatAction.ToggleSelection -> {
viewModel.dispatchEvent(
ChatViewModel.Event.ToggleMessageSelection(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ import com.getcode.theme.CodeTheme
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.theme.CodeCircularProgressIndicator
import com.getcode.ui.theme.CodeScaffold
import com.getcode.util.DateUtils
import com.getcode.util.resources.LocalResources
import com.flipcash.app.messenger.internal.joinedLine
import com.getcode.view.LoadingSuccessState
import kotlin.time.Instant

Expand Down Expand Up @@ -228,10 +229,7 @@ internal fun ProfileHeader(
joinDate?.let { instant ->
Text(
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
text = stringResource(
R.string.subtitle_joinedDate,
DateUtils.getDate(instant.toEpochMilliseconds(), "MMMM yyyy"),
),
text = joinedLine(instant, LocalResources.current),
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ class ChatGroupCashLinkTest {
cashLinkClaims = cashLinkClaims,
chatCashLinks = chatCashLinks,
chatDraftStore = chatDraftStore,
recentReactionsStore = mockk(relaxed = true),
emojiCatalogLoader = mockk(relaxed = true),
toastController = mockk(relaxed = true),
userProfileDataSource = mockk(relaxed = true),
dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.flipcash.app.messenger.internal

import com.getcode.util.resources.AndroidResources
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.Locale
import java.util.TimeZone
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant

/** The profile screen and a person card both read this, so its wording is pinned here once. */
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class JoinedLineTest {

@Test
fun `a join date reads as the month and year`() {
Locale.setDefault(Locale.US)
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
val resources = AndroidResources(RuntimeEnvironment.getApplication())

// 2024-03-09T16:00:00Z
assertEquals("Joined March 2024", joinedLine(Instant.fromEpochSeconds(1_710_000_000), resources))
}
}
Loading
Loading