diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/JoinedLine.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/JoinedLine.kt new file mode 100644 index 0000000000..7ff0262b80 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/JoinedLine.kt @@ -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"), + ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index d977f51a2e..43e50a7b38 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -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. @@ -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/`, or the legacy + // `/tip/`. 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 } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt index 7b6f46910f..d172a73a5b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt @@ -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 @@ -39,5 +40,6 @@ internal object LinkCardModule { giftCard = { giftCard(it) }, tokenMetadata = { token(it) }, group = { group(it) }, + user = { user(it) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index dc804b720d..c31dca5b58 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -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 @@ -45,6 +46,7 @@ internal class LinkCardResolver( private val giftCard: suspend (entropy: String) -> Result, private val tokenMetadata: suspend (mint: Mint) -> Result, private val group: suspend (chatId: ChatId) -> Result, + private val user: suspend (identity: LinkCard.User.Identity) -> Result, ) : LinkCardResolution { data class Snapshot( @@ -63,6 +65,7 @@ internal class LinkCardResolver( private val cashQueries = mutableMapOf>() private val tokenQueries = mutableMapOf>() private val groupQueries = mutableMapOf>() + private val userQueries = mutableMapOf>() /** * The answers that have landed, readable without the lock and without suspending — which is the @@ -80,6 +83,7 @@ internal class LinkCardResolver( private val cashAnswers = ConcurrentHashMap() private val tokenAnswers = ConcurrentHashMap() private val groupAnswers = ConcurrentHashMap() + private val userAnswers = ConcurrentHashMap() private val _revision = MutableStateFlow(0) override val revision: StateFlow = _revision.asStateFlow() @@ -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)) } /** @@ -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 memoized( queries: MutableMap>, key: K, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/UserLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/UserLinkLookup.kt new file mode 100644 index 0000000000..e83179325f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/UserLinkLookup.kt @@ -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 = + 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), + ) +} 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 7de666e6b9..9b66896d82 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 @@ -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 @@ -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( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index af25b80020..a9670f6fd6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -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 @@ -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, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 289deb12fb..c28d5f0a56 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -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), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JoinedLineTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JoinedLineTest.kt new file mode 100644 index 0000000000..b5ecac0ac0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JoinedLineTest.kt @@ -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)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index 1d8ad9634d..0de0327e1a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -5,6 +5,7 @@ import com.flipcash.app.router.Router import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.ui.DetectedUrl import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.isUsernameShaped import com.getcode.opencode.model.core.bytes import com.getcode.solana.keys.Mint import dev.theolm.rinku.DeepLink @@ -45,13 +46,25 @@ class LinkCardClassifierTest { "chat" -> link.pathSegmentsForTest().getOrNull(1) ?.let { runCatching { UUID.fromString(it) }.getOrNull() } ?.let { DeeplinkType.GroupChatInvite(ChatId(it.bytes)) } - else -> null + "tip" -> link.pathSegmentsForTest().getOrNull(1) + ?.let { runCatching { UUID.fromString(it) }.getOrNull() } + ?.let { DeeplinkType.Tipcard(it.bytes) } + else -> link.profileLinkForTest() } } override fun dispatch(deepLink: DeepLink) = error("not used in classification tests") } + /** + * Vectors answered ahead of the canonical fixture, by name, with the card kind given. The + * fixture still records person cards as "not in phase 1"; iOS holds the same exception + * (`LinkCardClassifierTests.aheadOfFixture`). Each entry goes when the fixture is updated. + */ + private val aheadOfFixture = mapOf( + "tip-card-by-id" to "user", + ) + private fun fixture(): JSONObject = JSONObject( javaClass.classLoader!! .getResourceAsStream("link_detection.json")!! @@ -81,6 +94,11 @@ class LinkCardClassifierTest { val actual = classifier.firstCard(links) + aheadOfFixture[name]?.let { kind -> + assertEquals(kind, actual?.kindName, "vector `$name` is ahead of the fixture") + continue + } + val expectedCard = vector.optJSONObject("card") if (expectedCard == null) { assertEquals(null, actual, "vector `$name`: ${vector.getString("note")}") @@ -179,6 +197,56 @@ class LinkCardClassifierTest { assertNull(classifier.firstCard(listOf(DetectedUrl(0, url.length, url)))) } + // Person cards + + private fun cardFor(url: String): LinkCard? = + LinkCardClassifier(router).firstCard(listOf(DetectedUrl(0, url.length, url))) + + private val userId = UUID.fromString("2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b").bytes + + @Test + fun `a handle link becomes a person card`() { + val user = cardFor("https://flipcash.com/satoshi") as? LinkCard.User + + assertEquals(LinkCard.User.Identity.ByUsername("satoshi"), user?.identity) + assertEquals("@satoshi", user?.linkedHandle) + assertEquals(LinkCard.User.State.Loading, user?.state) + } + + @Test + fun `an id link becomes a person card`() { + listOf( + "https://flipcash.com/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b", + "https://flipcash.com/tip/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b", + ).forEach { url -> + val user = cardFor(url) as? LinkCard.User + assertEquals(LinkCard.User.Identity.ById(userId), user?.identity, url) + assertNull(user?.linkedHandle, url) + } + } + + /** The router reads any single segment as a handle, so only the host gate stops these. */ + @Test + fun `a person shaped link on another host stays a link`() { + listOf( + "https://discord.gg/x", + "https://t.me/satoshi", + "https://example.com/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b", + "https://example.com/tip/2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b", + ).forEach { assertNull(cardFor(it), it) } + } + + @Test + fun `a website page stays a link`() { + listOf( + "https://flipcash.com/download", + "https://flipcash.com/Privacy", + "https://flipcash.com/terms", + "https://flipcash.com/currencycreator", + "https://flipcash.com/api", + ).forEach { assertNull(cardFor(it), it) } + } + /** A jump wrapper is unwrapped once. One pointing at another jump is malformed, not a card. */ @Test fun `a jump wrapper pointing at another jump is not a card`() { @@ -191,6 +259,38 @@ class LinkCardClassifierTest { } } +private val LinkCard.kindName: String + get() = when (this) { + is LinkCard.Cash -> "cash" + is LinkCard.TokenInfo -> "token" + is LinkCard.GroupInvite -> "group" + is LinkCard.User -> "user" + } + +/** + * `AppRouter`'s bare-host person link. Its reserved list is `internal` to the router module, so a + * copy stands in here; `AppRouterTest` holds the real list to the website's pages. + */ +private fun DeepLink.profileLinkForTest(): DeeplinkType? { + val uri = uriForTest() + if (uri.host?.removePrefix("www.") != "flipcash.com") return null + val segment = uri.pathSegments.singleOrNull()?.lowercase() ?: return null + if (Regex("^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$").matches(segment)) { + return DeeplinkType.Tipcard(UUID.fromString(segment).bytes) + } + return if (segment.isUsernameShaped() && segment !in reservedProfilePathsForTest) { + DeeplinkType.TipcardByUsername(segment) + } else { + null + } +} + +private val reservedProfilePathsForTest = setOf( + "download", "privacy", "terms", "support", "help", "about", "blog", "legal", "currencycreator", + "app", "api", "assets", "fonts", "icons", "js", "v1", "pool", "wallet", + "login", "c", "cash", "verify", "token", "chat", "tip", +) + private fun DeepLink.uriForTest() = android.net.Uri.parse(data) private fun DeepLink.pathSegmentsForTest(): List = diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index 1ba86722de..903b94266b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.models.LinkCard import com.getcode.opencode.model.financial.Token @@ -47,6 +48,7 @@ class LinkCardResolverTest { giftCard = { Result.failure(IllegalStateException("offline")) }, tokenMetadata = { Result.failure(IllegalStateException("offline")) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) assertEquals(LinkCard.Cash.State.Unresolved, (resolver.resolve(card) as LinkCard.Cash).state) } @@ -58,6 +60,7 @@ class LinkCardResolverTest { giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) val state = (resolver.resolve(card) as LinkCard.Cash).state assertTrue(state is LinkCard.Cash.State.Resolved) @@ -75,6 +78,7 @@ class LinkCardResolverTest { }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) resolver.resolve(card) @@ -92,6 +96,7 @@ class LinkCardResolverTest { }, tokenMetadata = { Result.failure(IllegalStateException("offline")) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) resolver.resolve(card) @@ -107,6 +112,7 @@ class LinkCardResolverTest { giftCard = { Result.failure(IllegalStateException("not asked")) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) val resolved = resolver.resolve(tokenCard) as LinkCard.TokenInfo assertTrue(resolved.state is LinkCard.TokenInfo.State.Resolved) @@ -119,6 +125,7 @@ class LinkCardResolverTest { giftCard = { Result.failure(IllegalStateException("not asked")) }, tokenMetadata = { Result.failure(IllegalStateException("no metadata")) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) val resolved = resolver.resolve(tokenCard) as LinkCard.TokenInfo assertEquals(LinkCard.TokenInfo.State.Unresolved, resolved.state) @@ -143,6 +150,7 @@ class LinkCardResolverTest { }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) val before = (resolver.resolve(card) as LinkCard.Cash).state assertEquals(LinkCard.Cash.Claim.Claimable, (before as LinkCard.Cash.State.Resolved).claim) @@ -162,6 +170,7 @@ class LinkCardResolverTest { giftCard = { calls++; Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) val other = card.copy(entropy = "8mXeQ2vTb4pLzRw9dKcHfA") resolver.resolve(card) @@ -189,6 +198,7 @@ class LinkCardResolverTest { }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) val claimed = card.copy(entropy = "8mXeQ2vTb4pLzRw9dKcHfA") resolver.resolve(card) @@ -217,6 +227,7 @@ class LinkCardResolverTest { }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) @@ -233,6 +244,7 @@ class LinkCardResolverTest { giftCard = { Result.failure(IllegalStateException("not asked")) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(tokenCard) @@ -250,6 +262,7 @@ class LinkCardResolverTest { giftCard = { cashCalls++; Result.success(snapshot()) }, tokenMetadata = { tokenCalls++; Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) resolver.resolve(tokenCard) @@ -264,6 +277,7 @@ class LinkCardResolverTest { giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) assertNull(resolver.peek(card), "nothing has been asked yet") @@ -283,6 +297,7 @@ class LinkCardResolverTest { giftCard = { Result.failure(IllegalStateException("offline")) }, tokenMetadata = { Result.failure(IllegalStateException("offline")) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) @@ -298,6 +313,7 @@ class LinkCardResolverTest { giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) val before = resolver.revision.value @@ -315,6 +331,7 @@ class LinkCardResolverTest { giftCard = { Result.success(snapshot().copy(claim = LinkCard.Cash.Claim.Claimed)) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) val before = resolver.revision.value @@ -333,6 +350,7 @@ class LinkCardResolverTest { giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) val before = resolver.revision.value @@ -356,6 +374,7 @@ class LinkCardResolverTest { giftCard = { Result.failure(IllegalStateException("unused")) }, tokenMetadata = { Result.failure(IllegalStateException("unused")) }, group = { Result.failure(IllegalStateException("not a group")) }, + user = { Result.failure(IllegalStateException("unused")) }, ) assertEquals( LinkCard.GroupInvite.State.Unavailable, @@ -379,10 +398,65 @@ class LinkCardResolverTest { giftCard = { Result.failure(IllegalStateException("unused")) }, tokenMetadata = { Result.failure(IllegalStateException("unused")) }, group = { calls++; Result.success(resolved) }, + user = { Result.failure(IllegalStateException("unused")) }, ) assertEquals(resolved, (resolver.resolve(groupCard) as LinkCard.GroupInvite).state) assertEquals(resolved, (resolver.peek(groupCard) as LinkCard.GroupInvite).state) resolver.resolve(groupCard.copy(chatId = ChatId(ByteArray(16) { it.toByte() }))) assertEquals(1, calls) } + + private val userCard = LinkCard.User( + url = "https://flipcash.com/satoshi", + start = 0, + end = 28, + identity = LinkCard.User.Identity.ByUsername("satoshi"), + state = LinkCard.User.State.Loading, + ) + + private val resolvedUser = LinkCard.User.State.Resolved( + userId = ByteArray(16) { it.toByte() }.toList(), + isOwn = false, + profile = UserProfile( + displayName = "Satoshi", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ), + name = "Satoshi", + handle = "@satoshi", + joined = "Joined March 2024", + ) + + @Test + fun `a failed person lookup draws not found and is asked again`() = runTest { + var calls = 0 + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { calls++; Result.failure(IllegalStateException("no account")) }, + ) + assertEquals(LinkCard.User.State.NotFound, (resolver.resolve(userCard) as LinkCard.User).state) + assertNull(resolver.peek(userCard)) + resolver.resolve(userCard) + assertEquals(2, calls) + } + + @Test + fun `a resolved person is held for the visit`() = runTest { + var calls = 0 + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { calls++; Result.success(resolvedUser) }, + ) + assertEquals(resolvedUser, (resolver.resolve(userCard) as LinkCard.User).state) + assertEquals(resolvedUser, (resolver.peek(userCard) as LinkCard.User).state) + resolver.resolve(userCard.copy(start = 10, end = 38)) + assertEquals(1, calls) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/UserLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/UserLinkLookupTest.kt new file mode 100644 index 0000000000..97322d71b3 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/UserLinkLookupTest.kt @@ -0,0 +1,124 @@ +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.user.UserManager +import com.flipcash.shared.chat.models.LinkCard +import com.getcode.opencode.model.core.bytes +import com.getcode.util.resources.ResourceHelper +import kotlinx.coroutines.test.runTest +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import java.io.IOException +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class UserLinkLookupTest { + + private val theirId = UUID.fromString("2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b").bytes + private val myId = UUID.fromString("6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162").bytes + + private fun profile( + displayName: String = "Satoshi", + username: String? = "satoshi", + joinedAt: Instant? = Instant.fromEpochSeconds(1_710_000_000), + userId: List? = theirId, + ) = UserProfile( + displayName = displayName, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + joinedAt = joinedAt, + userId = userId, + username = username, + ) + + private fun lookup( + profiles: ProfileController, + viewer: List? = myId, + ) = UserLinkLookup( + profiles = profiles, + userManager = mock { on { accountId } doReturn viewer }, + resources = mock(), + ) + + @Test + fun `a link to someone else is not the viewer's own`() { + val state = userCardState(profile(), viewerId = myId, joined = { "Joined March 2024" }) + + assertEquals(theirId, state?.userId) + assertFalse(state!!.isOwn) + assertEquals("Satoshi", state.name) + assertEquals("@satoshi", state.handle) + assertEquals("Joined March 2024", state.joined) + } + + @Test + fun `a link to the viewer is their own`() { + val state = userCardState(profile(userId = myId), viewerId = myId, joined = { "" }) + + assertTrue(state!!.isOwn) + } + + @Test + fun `no join date leaves the detail line out`() { + val state = userCardState(profile(joinedAt = null), viewerId = myId, joined = { "unused" }) + + assertNull(state?.joined) + } + + @Test + fun `no handle leaves the handle line out`() { + val state = userCardState(profile(username = null), viewerId = myId, joined = { "" }) + + assertEquals("Satoshi", state?.name) + assertNull(state?.handle) + } + + @Test + fun `an unclaimed handle is not found`() = runTest { + val profiles = mock { + onBlocking { getProfileForUsername(any()) } doReturn + Result.success(profile(displayName = "", username = null, joinedAt = null, userId = null)) + } + + val result = lookup(profiles)(LinkCard.User.Identity.ByUsername("nobody")) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `a failed fetch is not found`() = runTest { + val profiles = mock { + onBlocking { getProfileForUser(any()) } doReturn Result.failure(IOException()) + } + + val result = lookup(profiles)(LinkCard.User.Identity.ById(theirId)) + + assertIs(result.exceptionOrNull()) + } + + /** An id link is fetched by id and a handle link by handle; neither goes through the other. */ + @Test + fun `each identity is fetched the way the link names it`() = runTest { + val profiles = mock { + // No join date: the mocked resources have no string to format it into. + onBlocking { getProfileForUser(any()) } doReturn Result.success(profile(joinedAt = null)) + } + + val result = lookup(profiles)(LinkCard.User.Identity.ById(theirId)) + + assertEquals(theirId, result.getOrNull()?.userId) + verify(profiles, never()).getProfileForUsername(any()) + } +} 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 95a7724b40..eb2136951e 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 @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint @@ -26,6 +27,14 @@ sealed interface ChatAction { * screen gates itself; a card never joins or buys on the reader's behalf. */ data class OpenGroup(val chatId: ChatId) : ChatAction + + /** + * Opens the person a link card names: their DM, pushed over this chat so Back returns here, or + * the reader's own tip card when [isOwn]. [profile] is carried because the DM's header renders + * from it on the first frame. A card never pays the DM's fee on the reader's behalf; the DM's + * own fee sheet asks for it. + */ + data class OpenUser(val userId: ID, val profile: UserProfile, val isOwn: Boolean) : ChatAction data object ViewProfile : ChatAction /** diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index e428dad715..54e6b8991f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -199,11 +199,12 @@ enum class MessagePart(val id: String) { /** * The rows this bubble draws, top to bottom: the text before its card, the card, and the text after - * it, each dropped when it would hold nothing but whitespace. A bubble with no card is one row, as - * it always was. + * it, each dropped when it would hold nothing but whitespace and punctuation. A bubble with no card + * is one row, as it always was. * - * The whitespace on either side of the link goes with it -- it was the gap around a word that now - * has a row of its own. Other links stay in whichever text row they fell in, underlined. + * The punctuation touching the link and the whitespace past it go with the link -- they were the + * gap and the brackets around a word that now has a row of its own. Punctuation past a space + * belongs to the words beside it. Other links stay in whichever text row they fell in, underlined. * * The reply citation goes on the first row and the "Edited" marker and receipt on the last, so a * split message reads as one message stacked in three pieces rather than as three messages. @@ -218,15 +219,19 @@ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List 0 && text[leadingEnd - 1].isPunctuation()) leadingEnd-- while (leadingEnd > 0 && text[leadingEnd - 1].isWhitespace()) leadingEnd-- var trailingStart = card.end + while (trailingStart < text.length && text[trailingStart].isPunctuation()) trailingStart++ while (trailingStart < text.length && text[trailingStart].isWhitespace()) trailingStart++ val parts = listOfNotNull( - text.substring(0, leadingEnd).takeIf { it.isNotBlank() }?.let { MessagePart.Leading to it }, + text.substring(0, leadingEnd).takeIf { it.saysSomething() }?.let { MessagePart.Leading to it }, MessagePart.Card to null, - text.substring(trailingStart).takeIf { it.isNotBlank() }?.let { MessagePart.Trailing to it }, + text.substring(trailingStart).takeIf { it.saysSomething() }?.let { MessagePart.Trailing to it }, ) return parts.mapIndexed { index, (part, segment) -> @@ -239,3 +244,21 @@ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List true + else -> false +} + +/** Whether a text row would hold anything but whitespace and stray punctuation. */ +private fun String.saysSomething(): Boolean = any { !it.isWhitespace() && !it.isPunctuation() } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt index a92ab948ed..b5b5286db8 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt @@ -1,7 +1,10 @@ package com.flipcash.shared.chat.models +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.asHandle import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem +import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint @@ -159,4 +162,69 @@ sealed interface LinkCard { val staffOnly: Boolean, ) } + + /** + * A person's link: `flipcash.com/`, `flipcash.com/`, or the legacy `/tip/`. + * + * Built from the person's public profile only. Their tip card's colours are not read, and + * neither is their DM fee: the card opens the DM, and the DM's own fee sheet asks for it. + */ + data class User( + override val url: String, + override val start: Int, + override val end: Int, + val identity: Identity, + val state: State, + ) : LinkCard { + + /** Who the link names, as the URL spells it. */ + sealed interface Identity { + data class ById(val userId: ID) : Identity + data class ByUsername(val username: String) : Identity + } + + /** + * The `@handle` the link itself names, or null for an id link. What a card with no account + * behind it shows as the name, since the link is all it has to go on. + */ + val linkedHandle: String? + get() = (identity as? Identity.ByUsername)?.username?.asHandle() + + sealed interface State { + /** The lookup is out. Drawn as a shimmer at a typical card's size. */ + data object Loading : State + + /** + * No account to show: the handle is unclaimed, or the lookup failed. Not remembered, + * so the next appearance asks again. + */ + data object NotFound : State + + /** The card's contents, already worded for display. */ + data class Resolved( + /** The profile's own id, which a fetched profile always carries. */ + val userId: ID, + /** + * Whether the link is the viewer's own, which sends a tap to their own tip card + * rather than into a chat with themselves. + */ + val isOwn: Boolean, + /** + * The fetched profile, carried whole because opening the DM takes it: the chat's + * header renders from it on the first frame. The card itself reads only the + * fields below and the picture. + */ + val profile: UserProfile, + /** The display name, else the `@handle`; null when the person has neither. */ + val name: String?, + /** "@handle", or null when there is none or it is already [name]. */ + val handle: String?, + /** "Joined March 2024", or null when the server gave no join date. */ + val joined: String?, + ) : State { + /** The picture's BlurHash: the avatar's preview and the card's backdrop. */ + val blurHash: String? get() = profile.profilePicture?.blurhash() + } + } + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt index 4808e414fd..2dc5a4335d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt @@ -264,7 +264,7 @@ internal object GroupInviteCardDefaults { private const val PREVIEW_URL = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" /** A rendition carrying only a blurhash: enough for the tint and the avatar's placeholder. */ -private fun previewPicture(blurhash: String) = MediaItem( +internal fun previewPicture(blurhash: String) = MediaItem( renditions = listOf( MediaItemRendition( role = MediaItemRendition.Role.THUMBNAIL, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 24a52485d0..7cd35393f0 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -93,6 +93,17 @@ internal fun LinkCardView( val live = rememberResolvedCard(card) val shape = CodeTheme.shapes.medium + if (live is LinkCard.User) { + // Hugs its text like a message rather than filling the row, and takes its own tap. + UserLinkCard( + card = live, + onClick = onClick?.let { click -> { click(live) } }, + modifier = modifier, + onLongClick = onLongClick, + ) + return + } + if (live is LinkCard.GroupInvite) { // Only the button opens a group, so the card takes no tap of its own. The long press // still has to reach the transcript, or pressing the card would select nothing. @@ -136,7 +147,7 @@ internal fun LinkCardView( is LinkCard.Cash -> CashLinkCard(card = live, height = height) is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height) // Drawn above; unreachable here. - is LinkCard.GroupInvite -> Unit + is LinkCard.GroupInvite, is LinkCard.User -> Unit } } } 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 6e98bdc074..27cc6cbe88 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -560,7 +561,12 @@ private fun BareLinkCard( verticalPadding = 0.dp, attention = attention, ) { - Column(verticalArrangement = Arrangement.spacedBy(BubbleDefaults.surroundInset)) { + Column( + // A person's card hugs its text, so the column is as wide as the wider of it and a + // reply's quote, and the card fills that. Every other card fills the bubble's width. + modifier = Modifier.addIf(card is LinkCard.User) { Modifier.width(IntrinsicSize.Max) }, + verticalArrangement = Arrangement.spacedBy(BubbleDefaults.surroundInset), + ) { if (quote != null) { ChatQuotePanel( quote = quote, @@ -571,6 +577,7 @@ private fun BareLinkCard( } LinkCardView( card = card, + modifier = Modifier.addIf(card is LinkCard.User) { Modifier.fillMaxWidth() }, // Dropped with the backdrop up, like every other target on the row. onClick = if (interactive) onCardClick else null, // The card takes the press for its own tap, so it has to hand the transcript's @@ -606,6 +613,11 @@ private fun rememberLinkCardClick(): (LinkCard) -> Unit { // Pushed over this chat rather than through the chat deep link, which replaces the // stack: Back has to return to the message that held the invite. is LinkCard.GroupInvite -> actionHandler(ChatAction.OpenGroup(card.chatId)) + // Only a resolved card takes a tap. Which screen it opens -- the person's DM, your own + // tip card, or nothing for the person already on the other end -- is the chat's call. + is LinkCard.User -> (card.state as? LinkCard.User.State.Resolved)?.let { + actionHandler(ChatAction.OpenUser(userId = it.userId, profile = it.profile, isOwn = it.isOwn)) + } } } } @@ -1069,6 +1081,38 @@ private fun Preview_LinkCard_GroupInviteBesideCash() { } } +/** + * A person's link, trailing punctuation and all, between a group invite and a cash link: the person + * card hugs its text where the other two fill the column. + */ +@Preview(heightDp = 900) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_LinkCard_UserBesideGroupAndCash() { + val group = previewGroupCard(PreviewGroupResolved) + val user = previewUserCard(previewUserResolved()) + val userText = "say hi to ${user.url}." + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PreviewSplitMessage( + text = userText, + card = user.copy(start = userText.indexOf(user.url), end = userText.indexOf(user.url) + user.url.length), + ) + PreviewSplitMessage(text = group.url, card = group) + PreviewSplitMessage( + text = PREVIEW_CASH_LINK, + isFromSelf = true, + card = previewCard( + text = PREVIEW_CASH_LINK, + state = LinkCard.Cash.State.Resolved( + amount = "$5.00", + claim = LinkCard.Cash.Claim.Claimable, + token = Token.usdf, + ), + ), + ) + } +} + @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt new file mode 100644 index 0000000000..da5493fe45 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt @@ -0,0 +1,399 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +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 com.flipcash.app.core.ui.shimmer +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.rememberBlurHashPainter +import com.getcode.theme.CodeTheme +import com.getcode.ui.core.addIf +import kotlin.time.Instant + +/** + * A person's link: a compact row, the avatar beside the name, handle and join date, as wide as its + * text needs up to the width it is given. Tapped as a whole; a card with no account behind it takes + * no tap. + * + * Built from the person's public profile only, and shares nothing with the tip card. The corner + * and border are the group invite card's; the rest is in [UserLinkCardDefaults]. + * + * At accessibility font sizes the avatar moves above the text and every line wraps, where a row + * would cut each one short. + * + * [onLongClick] is the transcript's selection gesture, handed back because the card takes the + * press for its own tap. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun UserLinkCard( + card: LinkCard.User, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, +) { + val shape = GroupInviteCardDefaults.SHAPE + val state = card.state + + if (state is LinkCard.User.State.Loading) { + Box( + modifier = modifier + .size(UserLinkCardDefaults.SHIMMER_WIDTH, UserLinkCardDefaults.SHIMMER_HEIGHT) + .clip(shape) + .background(CodeTheme.colors.surfaceVariant) + .shimmer(shape), + ) + return + } + + val resolved = state as? LinkCard.User.State.Resolved + val tap = onClick.takeIf { resolved != null } + val accessible = LocalDensity.current.fontScale >= UserLinkCardDefaults.ACCESSIBILITY_FONT_SCALE + // The picture's BlurHash filling the card, never the photo itself. With no picture the tint + // lies over the chat's own ground. + val backdrop = rememberBlurHashPainter(resolved?.blurHash) + + Box( + modifier = modifier + .clip(shape) + .background(CodeTheme.colors.background) + .addIf(backdrop != null) { Modifier.cropBehind(backdrop!!) } + .background(Color.Black.copy(alpha = UserLinkCardDefaults.TINT_OPACITY)) + .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, shape) + .then( + when { + tap != null -> Modifier.combinedClickable(onLongClick = onLongClick, onClick = tap) + // Nothing to open, but a long press still has to reach the transcript. + onLongClick != null -> Modifier.pointerInput(onLongClick) { + detectTapGestures(onLongPress = { onLongClick() }) + } + else -> Modifier + }, + ) + .padding( + start = UserLinkCardDefaults.LEADING_PADDING, + end = UserLinkCardDefaults.TRAILING_PADDING, + top = UserLinkCardDefaults.VERTICAL_PADDING, + bottom = UserLinkCardDefaults.VERTICAL_PADDING, + ), + ) { + if (accessible) { + Column(verticalArrangement = Arrangement.spacedBy(UserLinkCardDefaults.AVATAR_GAP)) { + Avatar(card, resolved) + Identity(card, resolved, accessible = true) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(UserLinkCardDefaults.AVATAR_GAP), + ) { + Avatar(card, resolved) + Identity(card, resolved, accessible = false) + } + } + } +} + +/** + * Draws [painter] behind the content, cropped to fill. Not `Modifier.paint`: with bounded + * constraints it raises the minimum size to the maximum, and the card would fill the bubble + * instead of hugging its text. + */ +private fun Modifier.cropBehind(painter: Painter): Modifier = drawBehind { + val intrinsic = painter.intrinsicSize + if (intrinsic.isUnspecified || intrinsic.width <= 0f || intrinsic.height <= 0f) { + with(painter) { draw(size) } + return@drawBehind + } + val scale = ContentScale.Crop.computeScaleFactor(intrinsic, size) + val drawn = Size(intrinsic.width * scale.scaleX, intrinsic.height * scale.scaleY) + translate(left = (size.width - drawn.width) / 2f, top = (size.height - drawn.height) / 2f) { + with(painter) { draw(drawn) } + } +} + +/** + * The person's picture, or their initials on the avatar gradient. With no account, the generic + * person glyph: there is no name to take initials from. + */ +@Composable +private fun Avatar(card: LinkCard.User, resolved: LinkCard.User.State.Resolved?) { + // Size first: a fixed size answers an intrinsic measurement without asking the avatar's + // BoxWithConstraints, which would throw. + val modifier = Modifier + .size(UserLinkCardDefaults.AVATAR) + .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, CircleShape) + .clip(CircleShape) + if (resolved != null) { + ContactAvatar( + image = resolved.profile.profilePicture, + displayName = resolved.name ?: card.linkedHandle.orEmpty(), + // The picture belongs to this profile, so the profile is what authorizes re-minting it. + access = BlobAccessContext.profile(resolved.userId), + modifier = modifier, + ) + } else { + ContactAvatar(contact = null, includeBorder = false, modifier = modifier) + } +} + +@Composable +private fun Identity( + card: LinkCard.User, + resolved: LinkCard.User.State.Resolved?, + accessible: Boolean, +) { + // Unlimited at accessibility sizes, where the stacked layout wraps rather than cutting a line. + fun lines(count: Int) = if (accessible) Int.MAX_VALUE else count + + // The account's name, or with no account the handle the link names: nothing for an id link. + val name = if (resolved != null) resolved.name else card.linkedHandle + // When the account joined, or that there is no account. + val detail = if (resolved != null) { + resolved.joined + } else { + stringResource(R.string.error_title_usernameNotFound) + } + + Column(verticalArrangement = Arrangement.spacedBy(UserLinkCardDefaults.LINE_GAP)) { + name?.let { + Text( + text = it, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = lines(UserLinkCardDefaults.NAME_LINES), + overflow = TextOverflow.Ellipsis, + ) + } + resolved?.handle?.let { + Text( + text = it, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain.copy(alpha = UserLinkCardDefaults.HANDLE_OPACITY), + maxLines = lines(1), + overflow = TextOverflow.Ellipsis, + ) + } + detail?.let { + Text( + text = it, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain.copy(alpha = UserLinkCardDefaults.DETAIL_OPACITY), + maxLines = lines(1), + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * This card's own values, one for one with iOS's `LinkUserCardContent.Layout`. The corner radius + * and border are [GroupInviteCardDefaults.SHAPE] and [GroupInviteCardDefaults.STROKE]. + */ +internal object UserLinkCardDefaults { + /** The black over the decoded BlurHash backdrop. Provisional, pending design sign-off. */ + const val TINT_OPACITY = 0.6f + + val AVATAR = 44.dp + + /** Between the avatar and the text beside it, or above it at accessibility sizes. */ + val AVATAR_GAP = 12.dp + + /** Above and below the content. */ + val VERTICAL_PADDING = 10.dp + + /** + * Before the avatar. Less than [TRAILING_PADDING] because the avatar's circle already leaves + * room at its edge. + */ + val LEADING_PADDING = 10.dp + + /** After the text. */ + val TRAILING_PADDING = 16.dp + + /** Between the lines of text. */ + val LINE_GAP = 2.dp + + /** + * The name wraps to this many lines before it ends in an ellipsis. Display names run to 64 + * characters; the handle and the joined line always fit on one. + */ + const val NAME_LINES = 2 + + /** The handle, set in `caption` under the `textMedium` name. */ + const val HANDLE_OPACITY = 0.5f + + /** The joined line or the not-found line, set in `caption`. */ + const val DETAIL_OPACITY = 0.45f + + /** The shimmer's size while the lookup is out, near a typical resolved card's. */ + val SHIMMER_WIDTH = 200.dp + val SHIMMER_HEIGHT = 64.dp + + /** + * The font scale at which the row stacks and stops truncating: Android's stand-in for iOS's + * accessibility text sizes, which have no direct equivalent here. + */ + const val ACCESSIBILITY_FONT_SCALE = 1.5f +} + +// region Previews + +private val PreviewUserId = List(16) { it.toByte() } + +internal fun previewUserCard( + state: LinkCard.User.State, + identity: LinkCard.User.Identity = LinkCard.User.Identity.ByUsername("satoshi"), + url: String = "https://flipcash.com/satoshi", +) = LinkCard.User(url = url, start = 0, end = url.length, identity = identity, state = state) + +internal fun previewUserResolved( + name: String? = "Satoshi Nakamoto", + handle: String? = "@satoshi", + joined: String? = "Joined March 2024", + blurHash: String? = "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + isOwn: Boolean = false, +) = LinkCard.User.State.Resolved( + userId = PreviewUserId, + isOwn = isOwn, + profile = UserProfile( + displayName = name.orEmpty(), + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + profilePicture = blurHash?.let(::previewPicture), + joinedAt = Instant.fromEpochSeconds(1_710_000_000), + userId = PreviewUserId, + username = handle?.removePrefix("@"), + ), + name = name, + handle = handle, + joined = joined, +) + +@Composable +private fun PreviewCard(card: LinkCard.User) { + Box(Modifier.width(300.dp).padding(8.dp)) { + UserLinkCard(card = card, onClick = {}) + } +} + +/** Someone else: the blurred picture behind the tint, the handle and the join date. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_Someone() { + PreviewCard(previewUserCard(previewUserResolved())) +} + +/** An id link to someone with no handle: name and join date only. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_NoHandle() { + PreviewCard( + previewUserCard( + state = previewUserResolved(handle = null), + identity = LinkCard.User.Identity.ById(PreviewUserId), + url = "https://flipcash.com/00010203-0405-0607-0809-0a0b0c0d0e0f", + ), + ) +} + +/** No picture: initials on the avatar gradient, and the tint over the chat's own background. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_NoPicture() { + PreviewCard(previewUserCard(previewUserResolved(blurHash = null))) +} + +/** No join date on the profile: the detail line is left out. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_NoJoinDate() { + PreviewCard(previewUserCard(previewUserResolved(joined = null))) +} + +/** The viewer's own link. Drawn the same; only the tap differs (their own tip card). */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_Own() { + PreviewCard(previewUserCard(previewUserResolved(isOwn = true))) +} + +/** An unclaimed handle: the placeholder avatar, the handle from the link, and no tap. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_NotFound() { + PreviewCard(previewUserCard(LinkCard.User.State.NotFound)) +} + +/** A 64-character display name: two lines, then an ellipsis, the card at its widest. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_LongName() { + PreviewCard( + previewUserCard( + previewUserResolved(name = "Satoshi Nakamoto the Pseudonymous Creator of a Currency Called B"), + ), + ) +} + +/** Accessibility font size: the avatar above the text, every line wrapping in full. */ +@Preview(fontScale = 2f) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_LargeFont() { + PreviewCard( + previewUserCard( + previewUserResolved(name = "Satoshi Nakamoto the Pseudonymous Creator of a Currency Called B"), + ), + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UserLink_Loading() { + PreviewCard(previewUserCard(LinkCard.User.State.Loading)) +} + +// endregion diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt index e41bcaa30a..311ee025bc 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt @@ -91,6 +91,39 @@ class SplitAroundLinkCardTest { ) } + @Test + fun `punctuation touching the link goes with the card`() { + assertEquals(listOf("hi", null), split("hi $link.").map { it.partText }) + assertEquals(listOf(MessagePart.Card), split("($link)").map { it.part }) + assertEquals(listOf(null, "then lunch"), split("$link, then lunch").map { it.partText }) + assertEquals(listOf(null, "enjoy"), split("\"$link\" enjoy").map { it.partText }) + } + + @Test + fun `punctuation past a space stays with its words`() { + assertEquals(listOf("is this you?", null), split("is this you? $link").map { it.partText }) + assertEquals(listOf("look:", null), split("look: $link").map { it.partText }) + } + + @Test + fun `a segment of only punctuation is dropped`() { + assertEquals(listOf(MessagePart.Card), split("$link !!").map { it.part }) + assertEquals(listOf(MessagePart.Card), split("... $link").map { it.part }) + } + + @Test + fun `symbols and emoji are kept`() { + assertEquals(listOf(null, "🎉"), split("$link 🎉").map { it.partText }) + assertEquals(listOf(null, "\$5"), split("$link \$5").map { it.partText }) + } + + @Test + fun `a split on punctuation still copies the whole message`() { + val text = "hi $link." + + assertEquals(listOf(text, text), split(text).map { it.plainText }) + } + @Test fun `other links stay in the text row they fell in`() { val rows = split("$link and https://flipcash.app")