From 0b4b618041703a0836b3a5c2cbda739b8fe02fa9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:36:20 -0400 Subject: [PATCH 01/20] feat(chat): list, create and revoke a host's invite links HostInvites holds a chat's invites with each link rebuilt from its secret_envelope, so the invite sheet and Manage Invites agree on which link is Current: the newest ACTIVE invite the viewer made that this device can open. Create keeps one secret per attempt until the server answers, so a retry after a dropped response resends the same token. An OK that comes back non-ACTIVE drops the secret and tries once more; a refused expires_at retries once at the next shorter preset. DENIED maps to NotAvailable and is not retried (CreateInvite is staff-only for now). --- .../com/flipcash/shared/chat/HostInvites.kt | 310 ++++++++++++++++++ .../flipcash/shared/chat/HostInvitesTest.kt | 245 ++++++++++++++ 2 files changed, 555 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt new file mode 100644 index 0000000000..b1265bcaa4 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -0,0 +1,310 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.toEnvelope +import com.flipcash.services.chat.toInviteSecretEnvelope +import com.flipcash.services.chat.toPayload +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteToken +import com.getcode.chatcipher.InviteCipher +import com.getcode.chatcipher.InviteLink +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** New Invite Link's Expires presets (node 12844:334). [Never] sends no `expires_at`. */ +enum class InviteExpiry(val duration: Duration?) { + OneDay(1.days), + SevenDays(7.days), + ThirtyDays(30.days), + Never(null), + ; + + /** The next shorter preset, for a server that refuses this one's `expires_at`. */ + val shorter: InviteExpiry? + get() = when (this) { + Never -> ThirtyDays + ThirtyDays -> SevenDays + SevenDays -> OneDay + OneDay -> null + } +} + +/** New Invite Link's Who can join presets (node 12844:334). 0 is no limit. */ +enum class InviteUses(val maxUses: Int) { + NoLimit(0), + One(1), + Ten(10), + Fifty(50), +} + +data class InviteSettings(val expiry: InviteExpiry, val uses: InviteUses) { + companion object { + /** The current link's settings when the sheet makes one: 7 days, no use limit. */ + val Default = InviteSettings(InviteExpiry.SevenDays, InviteUses.NoLimit) + } +} + +/** + * One of the host's invites, with its link when this device can rebuild it. [url] is null when + * the invite's `secret_envelope` is missing or won't open here: the host can still revoke it, but + * can't share it. The link is as good as the chat key, so [toString] never prints it. + */ +class HostInvite(val invite: Invite, val url: String?) { + val canShare: Boolean get() = url != null + val isActive: Boolean get() = invite.status == Invite.Status.ACTIVE + + override fun toString(): String = "HostInvite(status=${invite.status}, canShare=$canShare)" +} + +/** How a create attempt ended. */ +sealed interface InviteCreation { + class Created(val invite: HostInvite) : InviteCreation + + /** `TOO_MANY_INVITES`: the group holds the most ACTIVE links the server allows. */ + data object TooMany : InviteCreation + + /** + * `DENIED`. While `CreateInvite` is staff-only, a host with `can_invite` gets this too + * (spec Decision 10), so it is shown as invite links not being available yet. + */ + data object NotAvailable : InviteCreation + + /** Anything else. A transport failure keeps the attempt's secret for the retry. */ + class Failed(val cause: Throwable) : InviteCreation +} + +/** + * The invite links of the private groups this account hosts: listing them with their rebuilt + * links, making a new one, and revoking. One copy for the invite sheet and Manage Invites, so the + * link the sheet shares is the one Manage Invites marks Current. + * + * Nothing here logs a link, its secret or its token. + */ +@Singleton +class HostInvites internal constructor( + private val chatController: ChatController, + private val groupKeys: GroupKeySource, + private val keys: ChatKeySource, + private val cipher: InviteCipher, + private val clock: Clock, +) { + @Inject + constructor( + chatController: ChatController, + groupKeys: GroupKeySource, + keys: ChatKeySource, + cipher: InviteCipher, + ) : this(chatController, groupKeys, keys, cipher, Clock.System) + + private val invites = MutableStateFlow>>(emptyMap()) + + /** Each loaded chat's invites, newest first. A chat never loaded is absent. */ + val state: StateFlow>> = invites.asStateFlow() + + // One secret per create attempt, kept until the server answers for certain, so a retry after a + // dropped response sends the same token and the server hands back the first invite rather + // than making a second. + private val pending = mutableMapOf, ByteArray>() + private val createLock = Mutex() + + /** Fetches every page of [chatId]'s invites and replaces what was held for it. */ + suspend fun load(chatId: ChatId): Result> = runCatching { + val all = mutableListOf() + var options = QueryOptions() + do { + val page = chatController.getChatInvites(chatId, options).getOrThrow() + all += page.invites + options = options.copy(token = page.pagingToken) + } while (page.hasMore && page.pagingToken != null) + all.distinctBy { it.id } + .sortedByDescending { it.createdAt } + .map { HostInvite(it, linkFor(it)) } + }.onSuccess { list -> + invites.update { it + (chatId to list) } + } + + /** + * The link the sheet shares: the newest ACTIVE invite the viewer created whose link this + * device can rebuild. Null when there is none, and the sheet makes one on first Share or Copy. + */ + fun current(chatId: ChatId): HostInvite? = currentOf(invites.value[chatId].orEmpty(), keys.ownUserId()) + + /** + * Makes an invite with [settings]. If the server answers OK with an invite that is no longer + * ACTIVE, which only a reused token can cause, the secret is dropped and one new attempt made. + * A refused `expires_at` is retried once at the next shorter preset. + */ + suspend fun create(chatId: ChatId, settings: InviteSettings): InviteCreation = + createLock.withLock { createLocked(chatId, settings) } + + private suspend fun createLocked(chatId: ChatId, settings: InviteSettings): InviteCreation { + var attempt = settings + var clamped = false + var renewed = false + while (true) { + when (val result = attemptCreate(chatId, attempt)) { + is AttemptResult.Created -> { + val created = HostInvite(result.invite, result.url) + invites.update { all -> + val others = all[chatId].orEmpty().filterNot { it.invite.id == created.invite.id } + all + (chatId to listOf(created) + others) + } + return InviteCreation.Created(created) + } + AttemptResult.NotActive -> { + if (renewed) return InviteCreation.Failed(IllegalStateException("Created invite is not active")) + renewed = true + } + AttemptResult.ExpiryRefused -> { + val shorter = attempt.expiry.shorter + if (clamped || shorter == null) { + return InviteCreation.Failed(CreateInviteError.InvalidExpiresAt()) + } + clamped = true + attempt = attempt.copy(expiry = shorter) + } + is AttemptResult.Done -> return result.creation + } + } + } + + /** Revokes [inviteId]. Revoking an invite already revoked is OK, so a retry is safe. */ + suspend fun revoke(chatId: ChatId, inviteId: InviteId): Result = + chatController.revokeInvite(chatId, inviteId).onSuccess { + invites.update { all -> + val list = all[chatId] ?: return@update all + all + (chatId to list.map { item -> + if (item.invite.id == inviteId) { + HostInvite(item.invite.copy(status = Invite.Status.REVOKED), item.url) + } else { + item + } + }) + } + } + + /** Drops everything held. For logout: the invites belong to the account. */ + fun clear() { + invites.value = emptyMap() + pending.clear() + } + + private sealed interface AttemptResult { + class Created(val invite: Invite, val url: String) : AttemptResult + data object NotActive : AttemptResult + data object ExpiryRefused : AttemptResult + class Done(val creation: InviteCreation) : AttemptResult + } + + private suspend fun attemptCreate(chatId: ChatId, settings: InviteSettings): AttemptResult { + val own = keys.ownKeyPair() + ?: return AttemptResult.Done(InviteCreation.Failed(IllegalStateException("No account key pair"))) + val chatKey = groupKeys.key(chatId).getOrElse { return AttemptResult.Done(InviteCreation.Failed(it)) } + + val slot = chatId to settings + val secret = pending.getOrPut(slot) { cipher.newSecret() } + val sealed = runCatching { + val inviteId = cipher.inviteId(secret) + Triple( + InviteToken(cipher.inviteToken(secret)), + cipher.sealInviteKeyEnvelope(own, secret, chatId.bytes, chatKey).toEnvelope(), + cipher.sealInviteSecretEnvelope(own, secret, chatId.bytes, inviteId).toInviteSecretEnvelope(), + ) + }.getOrElse { + pending.remove(slot) + return AttemptResult.Done(InviteCreation.Failed(it)) + } + val (token, keyEnvelope, secretEnvelope) = sealed + + val result = chatController.createInvite( + chatId = chatId, + token = token, + keyEnvelope = keyEnvelope, + secretEnvelope = secretEnvelope, + maxUses = settings.uses.maxUses, + expiresAt = settings.expiry.duration?.let { clock.now() + it }, + ) + val invite = result.getOrElse { error -> + if (error.isAnswered()) pending.remove(slot) + return when (error) { + is CreateInviteError.TooManyInvites -> AttemptResult.Done(InviteCreation.TooMany) + is CreateInviteError.Denied -> AttemptResult.Done(InviteCreation.NotAvailable) + is CreateInviteError.InvalidExpiresAt -> AttemptResult.ExpiryRefused + else -> AttemptResult.Done(InviteCreation.Failed(error)) + } + } + pending.remove(slot) + if (invite.status != Invite.Status.ACTIVE) return AttemptResult.NotActive + return AttemptResult.Created(invite, InviteLink.url(secret)) + } + + private fun linkFor(invite: Invite): String? { + val own = keys.ownKeyPair() ?: return null + val payload = invite.secretEnvelope?.toPayload() ?: return null + return runCatching { + val secret = cipher.openInviteSecretEnvelope(own, invite.chatId.bytes, invite.id.bytes, payload) + // A secret for another invite would share a link that leads somewhere else. + check(cipher.inviteId(secret).contentEquals(invite.id.bytes)) + InviteLink.url(secret) + }.getOrNull() + } + + companion object { + /** How long Manage Invites keeps showing a link after it stops letting people in (spec Open 9). */ + val InactiveShownFor: Duration = 30.days + + internal fun currentOf(list: List, viewer: ID?): HostInvite? = + list.firstOrNull { it.isActive && it.canShare && it.invite.createdBy == viewer } + + /** + * Manage Invites' sections: ACTIVE links, then the rest, each newest first. An inactive link + * is hidden once [InactiveShownFor] has passed since it ended, which for an expired link is + * its expiry and otherwise, since the contract records no revoke or exhaust time, its + * creation. + */ + fun sections(list: List, now: Instant): Pair, List> { + val (active, inactive) = list.partition { it.isActive } + val shown = inactive.filter { item -> + val ended = when (item.invite.status) { + Invite.Status.EXPIRED -> item.invite.expiresAt ?: item.invite.createdAt + else -> item.invite.createdAt + } + now - ended <= InactiveShownFor + } + return active to shown + } + + /** Whether `GetChatInvites` or `RevokeInvite` failed because the viewer can no longer invite. */ + fun Throwable.isLostInviteAccess(): Boolean = + this is GetChatInvitesError.Denied || this is RevokeInviteError.Denied + + // The server answered, so the token is spent either way and a retry needs a new secret. + private fun Throwable.isAnswered(): Boolean = when (this) { + is CreateInviteError.Denied, + is CreateInviteError.NotFound, + is CreateInviteError.InvalidExpiresAt, + is CreateInviteError.TooManyInvites, + is CreateInviteError.InvalidSignature -> true + else -> false + } + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt new file mode 100644 index 0000000000..02f2640a8c --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt @@ -0,0 +1,245 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeInviteCipher +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.toInviteSecretEnvelope +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.InvitesPage +import com.getcode.chatcipher.InviteCipher +import com.getcode.chatcipher.InviteLink +import com.getcode.ed25519kmp.KeyPair +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import java.io.IOException +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.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class HostInvitesTest { + + private val now = Instant.fromEpochSeconds(1_800_000_000) + private val clock = object : Clock { + override fun now() = now + } + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self = List(32) { 1.toByte() } + private val other = List(32) { 2.toByte() } + private val ownKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val keys = object : ChatKeySource { + override fun ownKeyPair() = ownKeys + override fun ownUserId() = self + override suspend fun peerPublicKey(userId: List) = Result.success(ByteArray(32)) + } + private val groupKeys = GroupKeySource { Result.success(ByteArray(32) { 9 }) } + private val cipher = RecordingInviteCipher() + private val chatController = mockk() + private val subject = HostInvites(chatController, groupKeys, keys, cipher, clock) + + /** An invite as the server would list it, with a secret envelope this device can open. */ + private fun invite( + secret: ByteArray, + status: Invite.Status = Invite.Status.ACTIVE, + createdAt: Instant = now, + expiresAt: Instant? = null, + createdBy: List = self, + openable: Boolean = true, + ): Invite { + val id = cipher.inviteId(secret) + val envelope = cipher.sealInviteSecretEnvelope( + if (openable) ownKeys else KeyPair(ByteArray(32) { 99 }, ByteArray(64)), + secret, + chatId.bytes, + id, + ).toInviteSecretEnvelope() + return Invite( + id = InviteId(id), + chatId = chatId, + createdBy = createdBy, + maxUses = 0, + useCount = 0, + createdAt = createdAt, + expiresAt = expiresAt, + status = status, + secretEnvelope = envelope, + ) + } + + private fun secret(n: Int) = ByteArray(32) { (100 + n).toByte() } + + @Test + fun `load pages to the end and keeps newest first`() = runTest { + val older = invite(secret(1), createdAt = now - 2.days) + val newer = invite(secret(2), createdAt = now - 1.days) + coEvery { chatController.getChatInvites(chatId, QueryOptions()) } returns + Result.success(InvitesPage(listOf(older), pagingToken = listOf(1), hasMore = true)) + coEvery { chatController.getChatInvites(chatId, QueryOptions(token = listOf(1))) } returns + Result.success(InvitesPage(listOf(newer), pagingToken = null, hasMore = false)) + + val list = subject.load(chatId).getOrThrow() + + assertEquals(listOf(newer.id, older.id), list.map { it.invite.id }) + assertEquals(InviteLink.url(secret(2)), list.first().url) + } + + @Test + fun `current is the newest active link the viewer made and can open`() = runTest { + val unopenable = invite(secret(1), createdAt = now, openable = false) + val someoneElses = invite(secret(2), createdAt = now - 1.days, createdBy = other) + val revoked = invite(secret(3), createdAt = now - 2.days, status = Invite.Status.REVOKED) + val mine = invite(secret(4), createdAt = now - 3.days) + coEvery { chatController.getChatInvites(chatId, any()) } returns + Result.success(InvitesPage(listOf(unopenable, someoneElses, revoked, mine), null, false)) + + val list = subject.load(chatId).getOrThrow() + + assertFalse(list.first().canShare) + assertEquals(mine.id, subject.current(chatId)?.invite?.id) + } + + @Test + fun `create seals both envelopes and sends the token of one fresh secret`() = runTest { + val token = slot() + coEvery { + chatController.createInvite(chatId, capture(token), any(), any(), 0, now + 7.days) + } answers { + Result.success(invite(cipher.last)) + } + + val result = subject.create(chatId, InviteSettings.Default) + + val created = assertIs(result).invite + assertEquals(InviteLink.url(cipher.last), created.url) + assertTrue(token.captured.bytes.contentEquals(cipher.inviteToken(cipher.last))) + assertEquals(created.invite.id, subject.current(chatId)?.invite?.id) + } + + @Test + fun `a transport failure keeps the secret so the retry sends the same token`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } returnsMany listOf( + Result.failure(CreateInviteError.Other(IOException("dropped"))), + Result.success(invite(secret(0))), + ) + + assertIs(subject.create(chatId, InviteSettings.Default)) + subject.create(chatId, InviteSettings.Default) + + assertEquals(tokens[0], tokens[1]) + } + + @Test + fun `an answered refusal drops the secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.TooManyInvites()) + + assertEquals(InviteCreation.TooMany, subject.create(chatId, InviteSettings.Default)) + subject.create(chatId, InviteSettings.Default) + + assertTrue(tokens[0] != tokens[1]) + } + + @Test + fun `denied means not available and is not retried`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.Denied()) + + assertEquals(InviteCreation.NotAvailable, subject.create(chatId, InviteSettings.Default)) + coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `invalid signature fails without retry`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.InvalidSignature()) + + val failed = assertIs(subject.create(chatId, InviteSettings.Default)) + assertIs(failed.cause) + coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `an OK that is no longer active retries once with a new secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } answers { + val status = if (tokens.size == 1) Invite.Status.EXHAUSTED else Invite.Status.ACTIVE + Result.success(invite(cipher.last, status = status)) + } + + assertIs(subject.create(chatId, InviteSettings.Default)) + assertEquals(2, tokens.size) + assertTrue(tokens[0] != tokens[1]) + } + + @Test + fun `a refused expiry is retried once at the next shorter preset`() = runTest { + val expiries = mutableListOf() + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), captureNullable(expiries)) } answers { + if (expiries.size == 1) Result.failure(CreateInviteError.InvalidExpiresAt()) + else Result.success(invite(cipher.last)) + } + + subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) + + assertEquals(listOf(null, now + 30.days), expiries) + } + + @Test + fun `revoke marks the invite revoked so it is no longer current`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.success(InvitesPage(listOf(mine), null, false)) + coEvery { chatController.revokeInvite(chatId, mine.id) } returns Result.success(Unit) + subject.load(chatId) + + subject.revoke(chatId, mine.id).getOrThrow() + + assertNull(subject.current(chatId)) + assertEquals(Invite.Status.REVOKED, subject.state.value[chatId]?.single()?.invite?.status) + } + + @Test + fun `sections hide inactive links that ended more than 30 days ago`() { + val active = HostInvite(invite(secret(1)), url = "a") + val recentRevoke = HostInvite(invite(secret(2), status = Invite.Status.REVOKED, createdAt = now - 10.days), null) + val oldRevoke = HostInvite(invite(secret(3), status = Invite.Status.REVOKED, createdAt = now - 31.days), null) + val expiredRecently = HostInvite( + invite(secret(4), status = Invite.Status.EXPIRED, createdAt = now - 60.days, expiresAt = now - 5.days), + null, + ) + + val (shownActive, shownInactive) = HostInvites.sections(listOf(active, recentRevoke, oldRevoke, expiredRecently), now) + + assertEquals(listOf(active), shownActive) + assertEquals(listOf(recentRevoke, expiredRecently), shownInactive) + } + + @Test + fun `a host invite never prints its link`() { + val item = HostInvite(invite(secret(1)), InviteLink.url(secret(1))) + assertFalse(item.toString().contains("#s=")) + } +} + +/** Remembers the secret of the most recent create attempt. */ +private class RecordingInviteCipher( + private val fake: FakeInviteCipher = FakeInviteCipher(), +) : InviteCipher by fake { + lateinit var last: ByteArray + override fun newSecret(): ByteArray = fake.newSecret().also { last = it } +} From 99f8f75ec113cab196f1484d36f23440820cafcf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:42:52 -0400 Subject: [PATCH 02/20] feat(chat): remember a host's can_invite from GetChat The local store drops can_invite, so a chat read from disk always says the viewer can't invite. HostInvites keeps the value GetChat last returned for each chat it checked, and a DENIED from GetChatInvites or RevokeInvite clears it. Logout clears it with the invites. --- .../com/flipcash/shared/chat/HostInvites.kt | 20 ++++++++++++ .../chat/internal/RealChatCoordinator.kt | 3 ++ .../flipcash/shared/chat/HostInvitesTest.kt | 32 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt index b1265bcaa4..c4e1ce223a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -14,6 +14,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Invite import com.flipcash.services.models.chat.InviteId import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.ViewMode import com.getcode.chatcipher.InviteCipher import com.getcode.chatcipher.InviteLink import com.getcode.opencode.model.core.ID @@ -116,6 +117,13 @@ class HostInvites internal constructor( ) : this(chatController, groupKeys, keys, cipher, Clock.System) private val invites = MutableStateFlow>>(emptyMap()) + private val access = MutableStateFlow>(emptyMap()) + + /** + * Each checked chat's `can_invite`, as `GetChat` last returned it. Kept here because the local + * store does not carry the permission yet, so a chat read from disk always says false. + */ + val canInvite: StateFlow> = access.asStateFlow() /** Each loaded chat's invites, newest first. A chat never loaded is absent. */ val state: StateFlow>> = invites.asStateFlow() @@ -126,6 +134,17 @@ class HostInvites internal constructor( private val pending = mutableMapOf, ByteArray>() private val createLock = Mutex() + /** Reads [chatId]'s `can_invite` from the server into [canInvite]. A failure leaves it as it was. */ + suspend fun refreshAccess(chatId: ChatId): Result = + chatController.getChat(chatId, ViewMode.FULL_OR_REDACTED) + .map { it.viewerState?.permissions?.canInvite == true } + .onSuccess { allowed -> access.update { it + (chatId to allowed) } } + + /** Records that the viewer can no longer invite to [chatId], after a `DENIED`. */ + fun revokeAccess(chatId: ChatId) { + access.update { it + (chatId to false) } + } + /** Fetches every page of [chatId]'s invites and replaces what was held for it. */ suspend fun load(chatId: ChatId): Result> = runCatching { val all = mutableListOf() @@ -205,6 +224,7 @@ class HostInvites internal constructor( /** Drops everything held. For logout: the invites belong to the account. */ fun clear() { invites.value = emptyMap() + access.value = emptyMap() pending.clear() } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 4558d821fd..735ede2f93 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -11,6 +11,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.PaymentRequests import com.flipcash.shared.chat.RosterUpdateSink @@ -107,6 +108,7 @@ class RealChatCoordinator @Inject constructor( private val lobbyUpdates: LobbyUpdateSink = LobbyUpdateSink.None, private val rosterUpdates: RosterUpdateSink = RosterUpdateSink.None, private val paymentRequests: PaymentRequests? = null, + private val hostInvites: HostInvites? = null, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -436,6 +438,7 @@ class RealChatCoordinator @Inject constructor( lobbyUpdates.clear() rosterUpdates.clear() paymentRequests?.clear() + hostInvites?.clear() cluster.value = null supervisorJob.cancel() trace(tag = TAG, message = "teardown complete", type = TraceType.Process) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt index 02f2640a8c..d82410bd43 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt @@ -8,10 +8,13 @@ import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.CreateInviteError import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.Invite import com.flipcash.services.models.chat.InviteId import com.flipcash.services.models.chat.InviteToken import com.flipcash.services.models.chat.InvitesPage +import com.flipcash.services.models.chat.ViewerState import com.getcode.chatcipher.InviteCipher import com.getcode.chatcipher.InviteLink import com.getcode.ed25519kmp.KeyPair @@ -229,6 +232,35 @@ class HostInvitesTest { assertEquals(listOf(recentRevoke, expiredRecently), shownInactive) } + @Test + fun `refreshAccess keeps the server's can_invite until a denial takes it away`() = runTest { + coEvery { chatController.getChat(chatId, any()) } returns Result.success( + ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = now, + viewerState = ViewerState(permissions = ViewerState.Permissions(canInvite = true)), + ), + ) + + assertTrue(subject.refreshAccess(chatId).getOrThrow()) + assertEquals(true, subject.canInvite.value[chatId]) + + subject.revokeAccess(chatId) + assertEquals(false, subject.canInvite.value[chatId]) + } + + @Test + fun `a failed refreshAccess leaves what was known`() = runTest { + subject.revokeAccess(chatId) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(IOException("offline")) + + assertTrue(subject.refreshAccess(chatId).isFailure) + assertEquals(false, subject.canInvite.value[chatId]) + } + @Test fun `a host invite never prints its link`() { val item = HostInvite(invite(secret(1)), InviteLink.url(secret(1))) From a1e62f91b2227bf6d4aa9b7dbc187f12f6263601 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:19:09 -0400 Subject: [PATCH 03/20] refactor(chat): end a refused invite expiry instead of retrying it shorter --- .../com/flipcash/shared/chat/HostInvites.kt | 27 +++---------------- .../flipcash/shared/chat/HostInvitesTest.kt | 17 ++++++------ 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt index c4e1ce223a..52a188bf92 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -37,16 +37,6 @@ enum class InviteExpiry(val duration: Duration?) { SevenDays(7.days), ThirtyDays(30.days), Never(null), - ; - - /** The next shorter preset, for a server that refuses this one's `expires_at`. */ - val shorter: InviteExpiry? - get() = when (this) { - Never -> ThirtyDays - ThirtyDays -> SevenDays - SevenDays -> OneDay - OneDay -> null - } } /** New Invite Link's Who can join presets (node 12844:334). 0 is no limit. */ @@ -170,17 +160,16 @@ class HostInvites internal constructor( /** * Makes an invite with [settings]. If the server answers OK with an invite that is no longer * ACTIVE, which only a reused token can cause, the secret is dropped and one new attempt made. - * A refused `expires_at` is retried once at the next shorter preset. + * Every other refusal, `INVALID_EXPIRES_AT` included, ends the attempt: the presets all fit the + * server's limits, so a refused expiry is a bug rather than something to retry around. */ suspend fun create(chatId: ChatId, settings: InviteSettings): InviteCreation = createLock.withLock { createLocked(chatId, settings) } private suspend fun createLocked(chatId: ChatId, settings: InviteSettings): InviteCreation { - var attempt = settings - var clamped = false var renewed = false while (true) { - when (val result = attemptCreate(chatId, attempt)) { + when (val result = attemptCreate(chatId, settings)) { is AttemptResult.Created -> { val created = HostInvite(result.invite, result.url) invites.update { all -> @@ -193,14 +182,6 @@ class HostInvites internal constructor( if (renewed) return InviteCreation.Failed(IllegalStateException("Created invite is not active")) renewed = true } - AttemptResult.ExpiryRefused -> { - val shorter = attempt.expiry.shorter - if (clamped || shorter == null) { - return InviteCreation.Failed(CreateInviteError.InvalidExpiresAt()) - } - clamped = true - attempt = attempt.copy(expiry = shorter) - } is AttemptResult.Done -> return result.creation } } @@ -231,7 +212,6 @@ class HostInvites internal constructor( private sealed interface AttemptResult { class Created(val invite: Invite, val url: String) : AttemptResult data object NotActive : AttemptResult - data object ExpiryRefused : AttemptResult class Done(val creation: InviteCreation) : AttemptResult } @@ -268,7 +248,6 @@ class HostInvites internal constructor( return when (error) { is CreateInviteError.TooManyInvites -> AttemptResult.Done(InviteCreation.TooMany) is CreateInviteError.Denied -> AttemptResult.Done(InviteCreation.NotAvailable) - is CreateInviteError.InvalidExpiresAt -> AttemptResult.ExpiryRefused else -> AttemptResult.Done(InviteCreation.Failed(error)) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt index d82410bd43..9bbb3863b3 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt @@ -191,16 +191,15 @@ class HostInvitesTest { } @Test - fun `a refused expiry is retried once at the next shorter preset`() = runTest { - val expiries = mutableListOf() - coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), captureNullable(expiries)) } answers { - if (expiries.size == 1) Result.failure(CreateInviteError.InvalidExpiresAt()) - else Result.success(invite(cipher.last)) - } - - subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) + fun `a refused expiry fails without retry`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.InvalidExpiresAt()) - assertEquals(listOf(null, now + 30.days), expiries) + val failed = assertIs( + subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) + ) + assertIs(failed.cause) + coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } } @Test From 72cf07cf791bbab65af05373b20df3f4a6f55ecf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:35:25 -0400 Subject: [PATCH 04/20] fix(shareable): keep a group invite's link out of its toString A private group's invite link carries its secret in the fragment, so Shareable.GroupInvite no longer prints the url. --- .../kotlin/com/flipcash/app/shareable/ShareSheetController.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index 7827617c05..d6fae2d769 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -67,6 +67,9 @@ sealed interface Shareable { val imageCacheKey: String? = null, ) : Shareable { override val pendingData: ShareablePendingData? = null + + // A private group's invite link carries its secret in the fragment; never print the url. + override fun toString(): String = "GroupInvite(title=$title)" } /** From 548a3bcc790bea3626bf555516e71e4ee63d0276 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:35:26 -0400 Subject: [PATCH 05/20] feat(chat): make a host link on the first share tap Adds HostInvites.currentOrCreate, which returns the current link or creates one with the default 7-day, no-limit settings. --- .../main/kotlin/com/flipcash/shared/chat/HostInvites.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt index 52a188bf92..4428131f3d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -157,6 +157,14 @@ class HostInvites internal constructor( */ fun current(chatId: ChatId): HostInvite? = currentOf(invites.value[chatId].orEmpty(), keys.ownUserId()) + /** + * The link the sheet shares, made when there is none: the first Share or Copy tap (spec + * Decision 9). The new link is 7 days with no use limit. [load] has to have run first, or a + * link that exists but was never fetched is not seen and a second one is made. + */ + suspend fun currentOrCreate(chatId: ChatId): InviteCreation = + current(chatId)?.let { InviteCreation.Created(it) } ?: create(chatId, InviteSettings.Default) + /** * Makes an invite with [settings]. If the server answers OK with an invite that is no longer * ACTIVE, which only a reused token can cause, the secret is dropped and one new attempt made. From 04048e231297ebc78bd8386a38a171df75543535 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:35:26 -0400 Subject: [PATCH 06/20] feat(ui): let a choice row take a content colour A destructive choice, such as Revoke Link, passes the error text colour. --- .../main/kotlin/com/getcode/ui/components/ChoiceRow.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt index f17616aac7..af2b07938d 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt @@ -13,6 +13,7 @@ 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.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -35,12 +36,14 @@ fun ChoiceRow( label: String, icon: Int, modifier: Modifier = Modifier, + contentColor: Color = CodeTheme.colors.textMain, onClick: () -> Unit, ) { ChoiceRow( label = label, icon = painterResource(icon), modifier = modifier, + contentColor = contentColor, onClick = onClick, ) } @@ -54,6 +57,8 @@ fun ChoiceRow( label: String, icon: Painter, modifier: Modifier = Modifier, + /** Icon and label colour; a destructive choice passes the error text colour. */ + contentColor: Color = CodeTheme.colors.textMain, onClick: () -> Unit, ) { Row( @@ -70,13 +75,13 @@ fun ChoiceRow( modifier = Modifier.size(IconSize), painter = icon, contentDescription = null, - tint = CodeTheme.colors.textMain, + tint = contentColor, ) Text( text = label, // 17sp Demi, matching the settings rows rather than the 16sp of `textMedium`. style = CodeTheme.typography.textMedium.copy(fontSize = 17.sp, lineHeight = 22.sp), - color = CodeTheme.colors.textMain, + color = contentColor, ) } } From 9b653aa0d855b63175f1b666ad8997ea369db23c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:35:27 -0400 Subject: [PATCH 07/20] feat(messenger): host surfaces for group invite links Adds the host's three-way invite sheet, the invite variant of the share-to-chats sheet (create on first tap, Recent Chats limited to private groups and encrypted DMs), Manage Invites with its row sheet, revoke and new-link sheets, and the Invite Links menu item. Join Requests' empty-state button reads Share Invite Link for a viewer who can invite. --- .../com/flipcash/app/core/chat/ChatStep.kt | 21 + .../main/res/drawable/ic_link_outlined.xml | 9 + .../core/src/main/res/values/strings.xml | 56 ++ .../flipcash/app/messenger/ChatFlowScreen.kt | 66 +++ .../internal/GroupInviteViewModel.kt | 28 +- .../internal/invites/InviteHostViewModel.kt | 74 +++ .../internal/invites/InviteLabels.kt | 84 +++ .../internal/invites/InviteLinkViewModel.kt | 225 ++++++++ .../internal/invites/ManageInvitesScreen.kt | 494 ++++++++++++++++++ .../invites/ManageInvitesViewModel.kt | 173 ++++++ .../internal/screens/GroupInviteSheet.kt | 170 +++++- .../screens/profile/GroupProfileScreen.kt | 23 +- .../screens/profile/JoinRequestsScreen.kt | 28 +- .../internal/GroupInviteViewModelTest.kt | 1 + .../invites/InviteLinkViewModelTest.kt | 200 +++++++ .../invites/ManageInvitesViewModelTest.kt | 171 ++++++ 16 files changed, 1806 insertions(+), 17 deletions(-) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index ef38496c65..6d2a0a20f8 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -87,6 +87,27 @@ sealed interface ChatStep : FlowStep, Parcelable { Sheet, com.getcode.navigation.FullscreenSheet + /** + * Frames 1b, 1f: a private group's invite link, in the share-to-chats sheet. [inviteIdHex] null + * is the sheet's current link, which the first Share or Copy tap makes when there is none; + * otherwise it is one invite from Manage Invites (2b's Share Link). + * + * Carries the invite's id and never its link: a step is logged and saved, and the link's + * fragment is the secret. The sheet rebuilds the link from the host's own invites. + */ + @Parcelize + @Serializable + data class ShareInviteLink(val inviteIdHex: String? = null) : + ChatStep, + NavigationRetVal, + Sheet, + com.getcode.navigation.FullscreenSheet + + /** Frame 2a: the invite links this host has made, Active then Inactive. Pushed, like [EditGroup]. */ + @Parcelize + @Serializable + data object ManageInvites : ChatStep + /** * Sharing one message into the viewer's other chats — [InviteToGroup]'s sheet with the * message's link as what it hands out. Returns the chat picked first, as that one does. diff --git a/apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml b/apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml new file mode 100644 index 0000000000..454ca6294a --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 01880b39b8..ee5486213a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1479,6 +1479,62 @@ Invite People Share Recent Chats + + + Invite to %1$s + Share Invite Link + Anyone with this link joins right away + Share Join Link + People ask to join and you approve each one + Manage Invites + See and revoke the invite links you\'ve made + Share Invite Link + Creating link… + Your link is made when you share or copy it + Share Link + Anyone with this link can join and see who\'s in the group. Share it only with people you want in the group + + Created %1$s + Expires %1$s + Never expires + %1$d joined + %1$d of %2$d joined + Can\'t share from this device + Expired %1$s + Revoked + Reached its limit + Current + Invite Links + Invite Link + ACTIVE + INACTIVE + New Link + No invite links + Make a link to let people join without waiting for approval. + Revoke Link + Revoke this link? + No one new can join with it. People who already joined stay in the group + New Invite Link + Expires + Who can join + 1 day + 7 days + 30 days + Never + No limit + 1 person + 10 + 50 + Create Link + Too Many Active Links + This group has as many active invite links as it can have. Revoke one in Manage Invites to make another + Invite Links Aren\'t Available Yet + You can still share the group link. People who open it can ask to join, and you approve them + Share Group Link + Couldn\'t Create Link + Something went wrong. Please try again + Couldn\'t Revoke Link + Couldn\'t Load Invite Links Send to Chats Suggested diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index e1e5f6062b..e5dd92fad3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -38,6 +38,8 @@ import com.flipcash.app.core.util.Linkify import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.messenger.internal.invites.InviteLinkViewModel +import com.flipcash.app.messenger.internal.invites.ManageInvitesScreen import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen @@ -45,6 +47,7 @@ import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet import com.flipcash.app.messenger.internal.screens.ReactorsSheet import com.flipcash.app.messenger.internal.screens.ReactorsViewModel import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject +import com.flipcash.app.messenger.internal.screens.openGroupInvite import com.flipcash.app.messenger.internal.request.ChatRequestsViewModel import com.flipcash.app.messenger.internal.request.RequestDetailsScreen import com.flipcash.app.messenger.internal.request.RequestMoneyScreen @@ -170,6 +173,12 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupInviteSheet() } + annotatedEntry { step -> + FlowInviteLinkSheet(step.inviteIdHex) + } + annotatedEntry { + ManageInvitesScreen(flowSharedViewModel().stateFlow.collectAsStateWithLifecycle().value.chatId) + } annotatedEntry { step -> FlowShareMessageSheet(step.messageId) } @@ -514,6 +523,63 @@ private fun FlowGroupInviteSheet() { ) } +/** + * [ChatStep.ShareInviteLink]: the invite sheet's invite variant (frames 1b, 1f). The first Share or + * Copy tap makes a link when the host has none; sending goes through the same view model as the + * join variant, limited to chats an invite link may be sent into. + */ +@Composable +private fun FlowInviteLinkSheet(inviteIdHex: String?) { + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val inviteViewModel = hiltViewModel() + val inviteState by inviteViewModel.state.collectAsStateWithLifecycle() + val linkViewModel = hiltViewModel() + val link by linkViewModel.state.collectAsStateWithLifecycle() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val keyboard = rememberKeyboardController() + val navigator = LocalCodeNavigator.current + val resultBack = resultBackNavigator(exit = dismissSheet) + + LaunchedEffect(inviteViewModel, state.chatId) { + state.chatId?.let(inviteViewModel::exclude) + inviteViewModel.restrictToEncrypted(true) + } + LaunchedEffect(linkViewModel, state.chatId, inviteIdHex) { + state.chatId?.let { linkViewModel.open(it, inviteIdHex) } + } + LaunchedEffect(inviteViewModel) { + inviteViewModel.invited.collect { chatId -> + keyboard.hideIfVisible { resultBack.returnValue(GroupInviteResult(chatId)) } + } + } + + GroupInviteSheet( + inviteUrl = null, + subject = ShareToChatsSubject.GroupInviteLink(state.subject as? ChatSubject.Group, link.link), + state = inviteState, + onShare = linkViewModel::onShare, + onCopy = linkViewModel::onCopy, + onToggle = inviteViewModel::toggle, + onMessageChanged = inviteViewModel::onMessageChanged, + onInvite = linkViewModel::onSend, + onDismiss = dismissSheet, + inviteEffects = linkViewModel.effects, + onInviteEffect = { effect -> + when (effect) { + is InviteLinkViewModel.Effect.Send -> inviteViewModel.invite(effect.url) + // 1e's offer: the plain join link, in place of this sheet. + InviteLinkViewModel.Effect.OpenJoinLink -> { + dismissSheet() + navigator.openGroupInvite() + } + InviteLinkViewModel.Effect.Close -> dismissSheet() + else -> Unit + } + }, + ) +} + /** * [ChatStep.ShareMessage]: the invite sheet handing out one message's link. Same send path and * result as [FlowGroupInviteSheet]; the link is the whole subject. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt index 663bb5c4c6..659f59f1cf 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.share.TipCodePreview import com.flipcash.features.messenger.R +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator @@ -49,6 +50,7 @@ internal class GroupInviteViewModel @Inject constructor( private val resources: ResourceHelper, private val clipboardManager: ClipboardManager, private val tipCodePreviewCache: TipCodePreviewCache, + private val e2eePolicy: E2eePolicy, ) : ViewModel() { data class State( @@ -63,6 +65,13 @@ internal class GroupInviteViewModel @Inject constructor( val selection: List = emptyList(), val message: String = "", val sending: Boolean = false, + /** The recent chats that are private groups or end-to-end encrypted DMs. */ + val encryptedChats: Set = emptySet(), + /** + * An invite link's secret may only go into a chat that is encrypted, so the invite variant + * of the sheet offers just those (spec surface 1). + */ + val encryptedOnly: Boolean = false, ) { /** The message bar only comes up once there is someone to send to (node 10330:19549). */ val showsComposer: Boolean get() = selection.isNotEmpty() @@ -72,7 +81,9 @@ internal class GroupInviteViewModel @Inject constructor( * sending a person their own profile is pointless. */ val invitable: List? - get() = recentChats?.filterNot { it.chatId == excludedChatId } + get() = recentChats?.filterNot { + it.chatId == excludedChatId || (encryptedOnly && it.chatId !in encryptedChats) + } } private val _state = MutableStateFlow(State()) @@ -94,7 +105,10 @@ internal class GroupInviteViewModel @Inject constructor( val summaries = chatCoordinator.chatListFeed().first() val selfId = userManager.accountId val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources, exchange = exchange) } - _state.update { it.copy(recentChats = chats) } + val encrypted = summaries + .filter { it.metadata.isPrivate || e2eePolicy.shouldEncrypt(it.metadata) } + .mapTo(mutableSetOf()) { it.metadata.chatId } + _state.update { it.copy(recentChats = chats, encryptedChats = encrypted) } } } @@ -105,6 +119,16 @@ internal class GroupInviteViewModel @Inject constructor( } } + /** Limits Recent Chats to private groups and encrypted DMs, for an invite link. */ + fun restrictToEncrypted(encryptedOnly: Boolean) { + _state.update { state -> + state.copy( + encryptedOnly = encryptedOnly, + selection = if (encryptedOnly) state.selection.filter { it in state.encryptedChats } else state.selection, + ) + } + } + /** The rendered share-sheet card for [userId], if one is ready. Null shares the link alone. */ fun previewFor(userId: ID): TipCodePreview? = tipCodePreviewCache.get(userId) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt new file mode 100644 index 0000000000..5d12096221 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt @@ -0,0 +1,74 @@ +package com.flipcash.app.messenger.internal.invites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.funding.bottomBarCardAction +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.HostInvites +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The host's way into invite links, for the screens that hand the group out: whether the viewer + * can invite (`can_invite`), and the three-row sheet that opens from the profile's share circle + * and Join Requests' empty state (frame 1a). + * + * Kept out of the conversation's view model: nothing here is about the transcript. + */ +@HiltViewModel +internal class InviteHostViewModel @Inject constructor( + private val hostInvites: HostInvites, + private val resources: ResourceHelper, +) : ViewModel() { + + /** Each checked chat's `can_invite`; a chat not checked yet reads as false. */ + val canInvite: StateFlow> = hostInvites.canInvite + + /** Asks the server whether the viewer can invite to [chatId]. A failure keeps the last answer. */ + fun refresh(chatId: ChatId) { + viewModelScope.launch { hostInvites.refreshAccess(chatId) } + } + + /** + * Frame 1a, drawn by the bottom bar's card sheet that Group Money and Add Money use, so it + * keeps their spacing, colours and dismissal. + */ + fun presentHostSheet( + groupName: String, + onShareInviteLink: () -> Unit, + onShareJoinLink: () -> Unit, + onManageInvites: () -> Unit, + ) { + BottomBarManager.showMessage( + title = resources.getString(R.string.title_inviteToGroup, groupName), + actions = listOf( + bottomBarCardAction( + title = resources.getString(R.string.action_shareInviteLinkLong), + subtitle = resources.getString(R.string.subtitle_shareInviteLink), + iconRes = R.drawable.ic_send_outlined, + testTag = "invite_action_share_invite", + onClick = onShareInviteLink, + ), + bottomBarCardAction( + title = resources.getString(R.string.action_shareJoinLink), + subtitle = resources.getString(R.string.subtitle_shareJoinLink), + iconRes = R.drawable.ic_link_outlined, + testTag = "invite_action_share_join", + onClick = onShareJoinLink, + ), + bottomBarCardAction( + title = resources.getString(R.string.action_manageInvites), + subtitle = resources.getString(R.string.subtitle_manageInvites), + iconRes = R.drawable.ic_checklist, + testTag = "invite_action_manage", + onClick = onManageInvites, + ), + ), + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt new file mode 100644 index 0000000000..64b06df4c1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt @@ -0,0 +1,84 @@ +package com.flipcash.app.messenger.internal.invites + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.Invite +import com.flipcash.shared.chat.HostInvite +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.time.Instant + +/** "Nov 3": a link's dates are read as days, so the year and time are left out. */ +internal fun shortDate( + at: Instant, + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String = DateTimeFormatter.ofPattern("MMM d", locale) + .withZone(zone) + .format(java.time.Instant.ofEpochMilli(at.toEpochMilliseconds())) + +/** How many have joined through a link: "12 joined" for no limit, "3 of 10 joined" for a cap. */ +internal data class JoinedCount(val useCount: Long, val maxUses: Long) { + val isCapped: Boolean get() = maxUses > 0 +} + +/** What a link's line says about when it ends, or why it can't be used. */ +internal sealed interface LinkStatus { + data class Expires(val at: Instant) : LinkStatus + data object NeverExpires : LinkStatus + data class Expired(val at: Instant?) : LinkStatus + data object Revoked : LinkStatus + data object ReachedLimit : LinkStatus +} + +internal fun Invite.linkStatus(): LinkStatus = when (status) { + Invite.Status.ACTIVE, Invite.Status.UNKNOWN -> expiresAt?.let(LinkStatus::Expires) ?: LinkStatus.NeverExpires + Invite.Status.EXPIRED -> LinkStatus.Expired(expiresAt) + Invite.Status.REVOKED -> LinkStatus.Revoked + Invite.Status.EXHAUSTED -> LinkStatus.ReachedLimit +} + +@Composable +internal fun JoinedCount.label(): String = + if (isCapped) { + stringResource(R.string.label_inviteJoinedOf, useCount.toInt(), maxUses.toInt()) + } else { + stringResource(R.string.label_inviteJoined, useCount.toInt()) + } + +@Composable +internal fun LinkStatus.label(): String = when (this) { + is LinkStatus.Expires -> stringResource(R.string.label_inviteExpires, shortDate(at)) + LinkStatus.NeverExpires -> stringResource(R.string.label_inviteNeverExpires) + is LinkStatus.Expired -> at?.let { stringResource(R.string.label_inviteExpired, shortDate(it)) } + ?: stringResource(R.string.label_inviteExpired, "") + LinkStatus.Revoked -> stringResource(R.string.label_inviteRevoked) + LinkStatus.ReachedLimit -> stringResource(R.string.label_inviteReachedLimit) +} + +/** The invite sheet's line under the link: "Expires Nov 3 · 0 joined". */ +@Composable +internal fun inviteMetaLine(expiresAt: Instant?, useCount: Long, maxUses: Long): String { + val ends = (expiresAt?.let(LinkStatus::Expires) ?: LinkStatus.NeverExpires).label() + return "$ends · ${JoinedCount(useCount, maxUses).label()}" +} + +/** Manage Invites' second line: joined first, then when it ends (or why it can't be shared). */ +@Composable +internal fun HostInvite.detailLine(): String { + val joined = JoinedCount(invite.useCount, invite.maxUses).label() + val second = when { + !isActive -> invite.linkStatus().label() + !canShare -> stringResource(R.string.label_inviteCantShare) + else -> invite.linkStatus().label() + } + return if (isActive) "$joined · $second" else "$second · $joined" +} + +/** The link as the pill shows it: scheme dropped and the middle of the secret elided (node 12933:113). */ +internal fun elideInviteUrl(url: String, head: Int = 37, tail: Int = 4): String { + val bare = url.removePrefix("https://").removePrefix("http://") + return if (bare.length <= head + tail + 1) bare else bare.take(head) + "…" + bare.takeLast(tail) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt new file mode 100644 index 0000000000..4af8ed69f7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt @@ -0,0 +1,225 @@ +package com.flipcash.app.messenger.internal.invites + +import android.content.ClipboardManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.extensions.setText +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.InviteCreation +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Instant + +/** The invite link the share sheet is showing (frames 1b and 1f). */ +internal sealed interface InviteLinkState { + /** Fetching the host's invites, to see whether a current link exists. */ + data object Loading : InviteLinkState + + /** No current link yet. The first Share or Copy tap makes one (spec Decision 9). */ + data object None : InviteLinkState + + /** Frame 1b: the first tap is making the link. */ + data object Creating : InviteLinkState + + /** + * Frame 1f. The link is as good as the chat key, so [toString] leaves it out and nothing + * here ever logs it. + */ + class Ready( + val url: String, + val expiresAt: Instant?, + val useCount: Long, + val maxUses: Long, + ) : InviteLinkState { + override fun toString(): String = "Ready(useCount=$useCount, maxUses=$maxUses)" + } +} + +/** + * Backs the invite variant of the share-to-chats sheet: the host's current link, or one invite + * picked in Manage Invites, and the first-tap creation of a current link when there is none. + * + * What happens after a tap is an [Effect] for the sheet to carry out, because sharing needs the + * share controller and the group's picture, which only the composition has. + */ +@HiltViewModel +internal class InviteLinkViewModel @Inject constructor( + private val hostInvites: HostInvites, + private val resources: ResourceHelper, + private val clipboardManager: ClipboardManager, +) : ViewModel() { + + enum class Action { Share, Copy, Send } + + sealed interface Effect { + /** Hand [url] to the system share sheet. */ + class Share(val url: String) : Effect { + override fun toString(): String = "Share" + } + + /** Send [url] to the chats picked in Recent Chats. */ + class Send(val url: String) : Effect { + override fun toString(): String = "Send" + } + + /** The link is on the clipboard. */ + data object Copied : Effect + + /** Frame 1e's offer: switch to the plain join link. */ + data object OpenJoinLink : Effect + + /** The invite the sheet was opened on cannot be shown; go back. */ + data object Close : Effect + } + + data class State( + val link: InviteLinkState = InviteLinkState.Loading, + /** `CreateInvite` answered DENIED: Share and Copy show 1e again instead of calling it. */ + val unavailable: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + + private var chatId: ChatId? = null + private var specific: String? = null + private var loaded = false + + /** Starts on [chatId]'s current link, or on the invite [inviteIdHex] when Manage Invites picked one. */ + fun open(chatId: ChatId, inviteIdHex: String?) { + if (this.chatId == chatId && specific == inviteIdHex) return + this.chatId = chatId + specific = inviteIdHex + loaded = false + _state.update { State() } + viewModelScope.launch { load(chatId) } + } + + private suspend fun load(chatId: ChatId): Boolean { + loaded = hostInvites.load(chatId).isSuccess + val link = specific + ?.let { id -> hostInvites.state.value[chatId].orEmpty().firstOrNull { it.invite.id.toHex() == id } } + ?: hostInvites.current(chatId).takeIf { specific == null } + _state.update { it.copy(link = link?.toState() ?: InviteLinkState.None) } + if (specific != null && link?.canShare != true) { + _effects.send(Effect.Close) + } + return loaded + } + + fun onShare() = act(Action.Share) + + fun onCopy() = act(Action.Copy) + + /** The sheet's Send, which makes the link first when a chat was picked before there was one. */ + fun onSend() = act(Action.Send) + + private fun act(action: Action) { + val chatId = chatId ?: return + val current = _state.value + when { + current.unavailable -> showUnavailable() + current.link is InviteLinkState.Ready -> perform(action, current.link.url) + current.link is InviteLinkState.None && specific == null -> create(chatId, action) + } + } + + private fun create(chatId: ChatId, action: Action) { + _state.update { it.copy(link = InviteLinkState.Creating) } + viewModelScope.launch { + // A load that failed on open could be hiding a link that exists; make none until one + // has gone through, or the tap would add a second. + if (!loaded && !load(chatId)) { + _state.update { it.copy(link = InviteLinkState.None) } + showFailed() + return@launch + } + val ready = (_state.value.link as? InviteLinkState.Ready) + if (ready != null) { + perform(action, ready.url) + return@launch + } + when (val result = hostInvites.currentOrCreate(chatId)) { + is InviteCreation.Created -> { + val link = result.invite.toState() + _state.update { it.copy(link = link) } + (link as? InviteLinkState.Ready)?.let { perform(action, it.url) } + } + InviteCreation.TooMany -> { + _state.update { it.copy(link = InviteLinkState.None) } + showTooMany() + } + InviteCreation.NotAvailable -> { + _state.update { it.copy(link = InviteLinkState.None, unavailable = true) } + showUnavailable() + } + is InviteCreation.Failed -> { + _state.update { it.copy(link = InviteLinkState.None) } + showFailed() + } + } + } + } + + private fun perform(action: Action, url: String) { + when (action) { + Action.Share -> _effects.trySend(Effect.Share(url)) + Action.Send -> _effects.trySend(Effect.Send(url)) + Action.Copy -> { + clipboardManager.setText( + text = url, + label = resources.getString(R.string.title_clipboardLabelGroupInviteLink), + ) + _effects.trySend(Effect.Copied) + } + } + } + + private fun showTooMany() = BottomBarManager.showAlert( + title = resources.getString(R.string.title_tooManyInviteLinks), + message = resources.getString(R.string.description_tooManyInviteLinks), + ) + + private fun showFailed() = BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotCreated), + message = resources.getString(R.string.description_inviteLinkNotCreated), + ) + + /** Frame 1e: invite links are not available to this host yet; the plain link still works. */ + private fun showUnavailable() = BottomBarManager.showMessage( + title = resources.getString(R.string.title_inviteLinksNotAvailable), + message = resources.getString(R.string.description_inviteLinksNotAvailable), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_shareGroupLink), + onClick = { _effects.trySend(Effect.OpenJoinLink) }, + ), + ), + showCancel = true, + ) + + private fun HostInvite.toState(): InviteLinkState = + url?.let { InviteLinkState.Ready(it, invite.expiresAt, invite.useCount, invite.maxUses) } + ?: InviteLinkState.None +} + +/** An invite's id as hex: what a nav step carries in place of the link. The id is not the secret. */ +internal fun com.flipcash.services.models.chat.InviteId.toHex(): String = + bytes.joinToString("") { "%02x".format(it) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt new file mode 100644 index 0000000000..81e427e4f0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt @@ -0,0 +1,494 @@ +package com.flipcash.app.messenger.internal.invites + +import android.os.Parcelable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.messenger.internal.screens.openInviteLinkShare +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.InviteExpiry +import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.White50 +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.ChoiceRow +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.launch + +/** + * Frames 2a to 2e: the host's invite links, Active then Inactive, with New Link and a row menu. + * A pushed step of the chat flow, reached from the invite sheet and the profile's overflow menu. + */ +@Composable +internal fun ManageInvitesScreen(chatId: ChatId?) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current + + LaunchedEffect(chatId) { chatId?.let(viewModel::open) } + + var rowMenu by remember { mutableStateOf(null) } + var newLink by remember { mutableStateOf(false) } + + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + // Back to the profile that opened this. + ManageInvitesViewModel.Effect.LostAccess -> flowNavigator.back() + ManageInvitesViewModel.Effect.Created -> newLink = false + } + } + } + + ManageInvitesContent( + state = state, + onBack = { flowNavigator.back() }, + onRow = { rowMenu = it }, + onNewLink = { newLink = true }, + ) + + rowMenu?.let { invite -> + InviteLinkActionsSheet( + invite = invite, + onShare = { + rowMenu = null + navigator.openInviteLinkShare(invite.invite.id.toHex()) + }, + onRevoke = { + rowMenu = null + viewModel.confirmRevoke(invite) + }, + onDismiss = { rowMenu = null }, + ) + } + if (newLink) { + NewInviteLinkSheet( + creating = state.creating, + onCreate = viewModel::create, + onDismiss = { newLink = false }, + ) + } +} + +/** Frames 2a and 2e, with no navigation or view model, so the previews and tests can draw them. */ +@Composable +internal fun ManageInvitesContent( + state: ManageInvitesViewModel.State, + onBack: () -> Unit, + onRow: (HostInvite) -> Unit, + onNewLink: () -> Unit, +) { + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_inviteLinks), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onBack, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .testTag("manage_invites_screen"), + ) { + if (state.isEmpty) { + EmptyInvites(modifier = Modifier.align(Alignment.Center), onNewLink = onNewLink) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + ) { + if (state.active.isNotEmpty()) { + item(key = "active") { + InviteSection( + header = stringResource(R.string.header_inviteLinksActive), + invites = state.active, + current = state.current, + onRow = onRow, + ) + } + } + if (state.inactive.isNotEmpty()) { + item(key = "inactive") { + InviteSection( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x5), + header = stringResource(R.string.header_inviteLinksInactive), + invites = state.inactive, + current = null, + onRow = null, + ) + } + } + // Room for the pinned button. + item(key = "clearance") { Box(Modifier.padding(bottom = 80.dp)) } + } + CodeButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_newInviteLink), + buttonState = ButtonState.Filled, + onClick = onNewLink, + ) + } + } + } +} + +@Composable +private fun InviteSection( + header: String, + invites: List, + current: HostInvite?, + onRow: ((HostInvite) -> Unit)?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(start = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x1), + text = header, + style = CodeTheme.typography.textSmall, + color = White50, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.small) + .background(White05), + ) { + invites.forEachIndexed { index, invite -> + if (index > 0) HorizontalDivider(color = CodeTheme.colors.divider) + InviteRow( + invite = invite, + isCurrent = current != null && current.invite.id == invite.invite.id, + onClick = onRow?.let { { it(invite) } }, + ) + } + } + } +} + +/** + * One link. An inactive row is greyed and has no click. A link this device can't rebuild says so + * and still opens its menu, which then offers only Revoke. + */ +@Composable +private fun InviteRow(invite: HostInvite, isCurrent: Boolean, onClick: (() -> Unit)?) { + val clickable = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + Row( + modifier = Modifier + .fillMaxWidth() + .then(clickable) + .alpha(if (invite.isActive) 1f else InactiveAlpha) + .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.label_inviteCreated, shortDate(invite.invite.createdAt)), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = invite.detailLine(), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + if (isCurrent) CurrentChip() + if (onClick != null) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = White50, + ) + } + } +} + +@Composable +private fun CurrentChip() { + Text( + modifier = Modifier + .clip(CircleShape) + .background(White05) + .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = 2.dp), + text = stringResource(R.string.label_inviteCurrent), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) +} + +/** Frame 2e. */ +@Composable +private fun EmptyInvites(onNewLink: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_noInviteLinks), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.widthIn(max = 280.dp), + text = stringResource(R.string.subtitle_noInviteLinks), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + CodeButton( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_newInviteLink), + buttonState = ButtonState.Filled10, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp), + overrideContentPadding = true, + onClick = onNewLink, + ) + } +} + +/** Frame 2b: Share Link and Revoke Link, in the mute sheet's style. A link that can't be shared gets Revoke only. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InviteLinkActionsSheet( + invite: HostInvite, + onShare: () -> Unit, + onRevoke: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = CodeTheme.colors.background, + ) { + InviteLinkActions(invite = invite, onShare = onShare, onRevoke = onRevoke, onDismiss = onDismiss) + } +} + +@Composable +internal fun InviteLinkActions( + invite: HostInvite, + onShare: () -> Unit, + onRevoke: () -> Unit, + onDismiss: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + AppBarWithTitle( + title = stringResource(R.string.title_inviteLink), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + text = "${stringResource(R.string.label_inviteCreated, shortDate(invite.invite.createdAt))} · ${invite.detailLine()}", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + if (invite.canShare) { + ChoiceRow( + label = stringResource(R.string.action_shareLink), + icon = R.drawable.ic_send_outlined, + onClick = onShare, + ) + } + ChoiceRow( + label = stringResource(R.string.action_revokeLink), + icon = rememberVectorPainter(Icons.Outlined.Block), + contentColor = CodeTheme.colors.errorText, + onClick = onRevoke, + ) + } + } +} + +/** Frame 2d: expiry and use-limit presets, and Create Link. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NewInviteLinkSheet( + creating: Boolean, + onCreate: (InviteSettings) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = CodeTheme.colors.background, + ) { + NewInviteLinkContent(creating = creating, onCreate = onCreate) + } +} + +@Composable +internal fun NewInviteLinkContent( + creating: Boolean, + onCreate: (InviteSettings) -> Unit, + initial: InviteSettings = InviteSettings.Default, +) { + var expiry by remember { mutableStateOf(initial.expiry) } + var uses by remember { mutableStateOf(initial.uses) } + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Text( + text = stringResource(R.string.title_newInviteLink), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.label_inviteExpiresHeader), + style = CodeTheme.typography.textSmall, + color = White50, + ) + OptionChips( + options = InviteExpiry.entries, + selected = expiry, + label = { stringResource(it.labelRes()) }, + onSelect = { expiry = it }, + ) + Text( + text = stringResource(R.string.label_inviteWhoCanJoin), + style = CodeTheme.typography.textSmall, + color = White50, + ) + OptionChips( + options = InviteUses.entries, + selected = uses, + label = { stringResource(it.labelRes()) }, + onSelect = { uses = it }, + ) + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_createInviteLink), + isLoading = creating, + enabled = !creating, + buttonState = ButtonState.Filled, + onClick = { onCreate(InviteSettings(expiry, uses)) }, + ) + } +} + +@Composable +private fun OptionChips( + options: List, + selected: T, + label: @Composable (T) -> String, + onSelect: (T) -> Unit, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + for (option in options) { + val isSelected = option == selected + Text( + modifier = Modifier + .clip(CircleShape) + .background(if (isSelected) CodeTheme.colors.textMain else White05) + .clickable { onSelect(option) } + .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x2), + text = label(option), + style = CodeTheme.typography.textSmall, + color = if (isSelected) CodeTheme.colors.background else CodeTheme.colors.textMain, + ) + } + } +} + +private fun InviteExpiry.labelRes(): Int = when (this) { + InviteExpiry.OneDay -> R.string.label_inviteExpiry_oneDay + InviteExpiry.SevenDays -> R.string.label_inviteExpiry_sevenDays + InviteExpiry.ThirtyDays -> R.string.label_inviteExpiry_thirtyDays + InviteExpiry.Never -> R.string.label_inviteExpiry_never +} + +private fun InviteUses.labelRes(): Int = when (this) { + InviteUses.NoLimit -> R.string.label_inviteUses_noLimit + InviteUses.One -> R.string.label_inviteUses_one + InviteUses.Ten -> R.string.label_inviteUses_ten + InviteUses.Fifty -> R.string.label_inviteUses_fifty +} + +/** Inactive rows are greyed (frame 2a). */ +private const val InactiveAlpha = 0.5f diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt new file mode 100644 index 0000000000..4d2c925839 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt @@ -0,0 +1,173 @@ +package com.flipcash.app.messenger.internal.invites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess +import com.flipcash.shared.chat.InviteCreation +import com.flipcash.shared.chat.InviteSettings +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Clock + +/** Frames 2a to 2e: the host's invite links, Active then Inactive. */ +@HiltViewModel +internal class ManageInvitesViewModel @Inject constructor( + private val hostInvites: HostInvites, + private val resources: ResourceHelper, +) : ViewModel() { + + data class State( + val loading: Boolean = true, + /** The first load failed, so an empty list means nothing was fetched rather than no links. */ + val failed: Boolean = false, + val active: List = emptyList(), + val inactive: List = emptyList(), + /** The link the invite sheet shares; its row carries the Current chip. */ + val current: HostInvite? = null, + val creating: Boolean = false, + ) { + val isEmpty: Boolean get() = !loading && !failed && active.isEmpty() && inactive.isEmpty() + } + + sealed interface Effect { + /** The viewer lost `can_invite`; back to the profile. */ + data object LostAccess : Effect + + /** A link was made; close the New Link sheet. */ + data object Created : Effect + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + + private var chatId: ChatId? = null + + fun open(chatId: ChatId) { + if (this.chatId == chatId) return + this.chatId = chatId + refresh() + } + + fun refresh() { + val chatId = chatId ?: return + _state.update { it.copy(loading = true, failed = false) } + viewModelScope.launch { + hostInvites.load(chatId) + .onSuccess { publish(chatId) } + .onFailure { error -> + if (error.isLostInviteAccess()) { + lostAccess(chatId) + } else { + _state.update { it.copy(loading = false, failed = true) } + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinksNotLoaded), + message = resources.getString(R.string.description_inviteLinkNotCreated), + ) + } + } + } + } + + /** Asks first, because revoking cannot be undone (frame 2c). */ + fun confirmRevoke(invite: HostInvite) { + BottomBarManager.showAlert( + title = resources.getString(R.string.title_revokeInviteLink), + message = resources.getString(R.string.description_revokeInviteLink), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_revokeLink)) { revoke(invite) }, + ), + showCancel = true, + ) + } + + private fun revoke(invite: HostInvite) { + val chatId = chatId ?: return + viewModelScope.launch { + hostInvites.revoke(chatId, invite.invite.id) + .onSuccess { publish(chatId) } + .onFailure { error -> + if (error.isLostInviteAccess()) { + lostAccess(chatId) + } else { + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotRevoked), + message = resources.getString(R.string.description_inviteLinkNotCreated), + ) + } + } + } + } + + /** Frame 2d's Create Link. The new link becomes the sheet's current link. */ + fun create(settings: InviteSettings) { + val chatId = chatId ?: return + if (_state.value.creating) return + _state.update { it.copy(creating = true) } + viewModelScope.launch { + when (val result = hostInvites.create(chatId, settings)) { + is InviteCreation.Created -> { + _state.update { it.copy(creating = false) } + publish(chatId) + _effects.send(Effect.Created) + } + InviteCreation.TooMany -> { + _state.update { it.copy(creating = false) } + BottomBarManager.showAlert( + title = resources.getString(R.string.title_tooManyInviteLinks), + message = resources.getString(R.string.description_tooManyInviteLinks), + ) + } + InviteCreation.NotAvailable -> { + _state.update { it.copy(creating = false) } + BottomBarManager.showMessage( + title = resources.getString(R.string.title_inviteLinksNotAvailable), + message = resources.getString(R.string.description_inviteLinksNotAvailable), + ) + } + is InviteCreation.Failed -> { + _state.update { it.copy(creating = false) } + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotCreated), + message = resources.getString(R.string.description_inviteLinkNotCreated), + ) + } + } + } + } + + private fun publish(chatId: ChatId) { + val (active, inactive) = HostInvites.sections(hostInvites.state.value[chatId].orEmpty(), Clock.System.now()) + _state.update { + it.copy( + loading = false, + failed = false, + active = active, + inactive = inactive, + current = hostInvites.current(chatId), + ) + } + } + + private fun lostAccess(chatId: ChatId) { + hostInvites.revokeAccess(chatId) + _effects.trySend(Effect.LostAccess) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index d5906ae982..7563da18b4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.getcode.theme.extraSmall import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -74,6 +75,11 @@ import com.flipcash.app.core.share.SharePreviewImage import com.flipcash.app.core.share.TipCodePreview import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.messenger.internal.invites.InviteLinkState +import com.flipcash.app.messenger.internal.invites.InviteLinkViewModel +import com.flipcash.app.messenger.internal.invites.elideInviteUrl +import com.flipcash.app.messenger.internal.invites.inviteMetaLine +import kotlinx.coroutines.flow.Flow import com.flipcash.app.shareable.LocalShareController import com.flipcash.app.shareable.Shareable import com.flipcash.features.messenger.R @@ -92,6 +98,7 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.sheetResignmentBehavior import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState @@ -119,6 +126,18 @@ internal fun CodeNavigator.openGroupInvite() { } } +/** + * Opens the invite variant of the sheet: the host's current link, or the invite [inviteIdHex] picked + * in Manage Invites. Returns the chat picked first, as [openGroupInvite] does. + */ +internal fun CodeNavigator.openInviteLinkShare(inviteIdHex: String? = null) { + navigateForResult(ChatStep.ShareInviteLink(inviteIdHex)) { result -> + if (result is NavResultOrCanceled.ReturnValue) { + navigate(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(result.value.chatId))) + } + } +} + /** Opens the sheet on a message's link; the chat picked first opens once the sends are out. */ internal fun CodeNavigator.openMessageShare(messageId: Long) { navigateForResult(ChatStep.ShareMessage(messageId)) { result -> @@ -141,7 +160,10 @@ internal fun cardTileTag(subject: ShareToChatsSubject): String? = * link has no card. */ internal fun showsCardTile(subject: ShareToChatsSubject, onShowCard: (() -> Unit)?): Boolean = - onShowCard != null && subject !is ShareToChatsSubject.Message && subject !is ShareToChatsSubject.Currency + onShowCard != null && + subject !is ShareToChatsSubject.Message && + subject !is ShareToChatsSubject.Currency && + subject !is ShareToChatsSubject.GroupInviteLink /** * What [GroupInviteSheet] hands out: a group's invite link, a person's profile link, or a link to @@ -155,6 +177,15 @@ internal sealed interface ShareToChatsSubject { */ data class Group(val group: ChatSubject.Group?) : ShareToChatsSubject + /** + * A private group's invite link (frames 1b and 1f): the join variant's sheet with a link pill, + * Share Link and Copy Link in place of the group card, and Recent Chats cut to the chats an + * invite link may be sent into. + * + * [link] carries the link; its `toString` leaves the link out, so this prints without it. + */ + data class GroupInviteLink(val group: ChatSubject.Group?, val link: InviteLinkState) : ShareToChatsSubject + /** * @param preview the tip-code image for the system share sheet's card, when one is rendered. */ @@ -204,6 +235,9 @@ internal fun GroupInviteSheet( onInvite: () -> Unit, onDismiss: () -> Unit, onShowCard: (() -> Unit)? = null, + /** The invite variant's Share, Copy and Send answers; see [InviteLinkViewModel.Effect]. */ + inviteEffects: Flow? = null, + onInviteEffect: (InviteLinkViewModel.Effect) -> Unit = {}, ) { val shareController = LocalShareController.current val scope = rememberCoroutineScope() @@ -211,7 +245,9 @@ internal fun GroupInviteSheet( // Resolved here rather than in the share controller: the stored download URL expires, and // re-minting it needs the chat's profile to authorize it — neither of which the shareable // module can see. Same size and access as the avatar the conversation bar draws. - val group = (subject as? ShareToChatsSubject.Group)?.group + val inviteLink = subject as? ShareToChatsSubject.GroupInviteLink + val group = (subject as? ShareToChatsSubject.Group)?.group ?: inviteLink?.group + val creating = inviteLink?.link is InviteLinkState.Creating val picture = group?.picture val pictureUrl = rememberMediaUrl( media = picture, @@ -230,6 +266,27 @@ internal fun GroupInviteSheet( var fieldHeight by remember { mutableStateOf(0.dp) } var copied by remember { mutableStateOf(false) } + if (inviteEffects != null) { + val groupTitle = group?.groupTitle + val cacheKey = picture?.cacheKeyForSize(SharePreviewImage.TARGET_PX) + LaunchedEffect(inviteEffects) { + inviteEffects.collect { effect -> + when (effect) { + is InviteLinkViewModel.Effect.Share -> shareController.present( + Shareable.GroupInvite( + url = effect.url, + title = groupTitle, + imageUrl = pictureUrl.url, + imageCacheKey = cacheKey, + ) + ) + InviteLinkViewModel.Effect.Copied -> copied = true + else -> onInviteEffect(effect) + } + } + } + } + // Held above the gesture bar, and dropped once the keyboard is up: the keyboard already lifts // the whole sheet clear of it, so counting both would leave a gap over the keys. val navigationBar = WindowInsets.navigationBars @@ -265,7 +322,17 @@ internal fun GroupInviteSheet( }, ), ) { - if (inviteUrl != null) { + if (inviteUrl != null || inviteLink != null) { + if (inviteLink != null) { + item(key = "pill") { + InviteLinkPill( + link = inviteLink.link, + modifier = Modifier + .padding(horizontal = ListInset) + .padding(top = TileRowTop), + ) + } + } item(key = "links") { // Intrinsic height, so a tile whose label wraps stretches the others to // match rather than standing taller than them. @@ -274,16 +341,24 @@ internal fun GroupInviteSheet( .fillMaxWidth() .height(IntrinsicSize.Min) .padding(horizontal = ListInset) - .padding(top = TileRowTop, bottom = TileRowBottom), + .padding( + top = if (inviteLink != null) TileRowBottom else TileRowTop, + bottom = TileRowBottom, + ), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { InviteLinkTile( modifier = Modifier.weight(1f).fillMaxHeight(), + enabled = !creating, icon = R.drawable.ic_share_os, - label = stringResource(R.string.action_shareInviteLink), + label = stringResource( + if (inviteLink != null) R.string.action_shareLink else R.string.action_shareInviteLink + ), onClick = { onShare() - scope.launch { + // The invite variant shares what the view model answers with, once + // there is a link to share. + if (inviteUrl != null) scope.launch { shareController.present( when (subject) { is ShareToChatsSubject.Group -> Shareable.GroupInvite( @@ -301,6 +376,7 @@ internal fun GroupInviteSheet( ) ShareToChatsSubject.Message -> Shareable.MessageLink(inviteUrl) is ShareToChatsSubject.Currency -> Shareable.MessageLink(inviteUrl) + is ShareToChatsSubject.GroupInviteLink -> error("shared by effect") } ) } @@ -308,6 +384,7 @@ internal fun GroupInviteSheet( ) InviteLinkTile( modifier = Modifier.weight(1f).fillMaxHeight(), + enabled = !creating, icon = if (copied) R.drawable.ic_check else R.drawable.ic_copy, label = stringResource( when { @@ -318,7 +395,8 @@ internal fun GroupInviteSheet( ), onClick = { onCopy() - copied = true + // The invite variant has nothing to copy until the link exists. + if (inviteLink == null) copied = true }, ) if (showsCardTile(subject, onShowCard)) { @@ -346,10 +424,22 @@ internal fun GroupInviteSheet( RecentChatRow( chat = chat, selected = chat.chatId in state.selection, - enabled = !state.sending, + enabled = !state.sending && !creating, onClick = { onToggle(chat.chatId) }, ) } + if (inviteLink != null) { + item(key = "footnote") { + Text( + modifier = Modifier + .padding(horizontal = ListInset) + .padding(top = TileRowBottom), + text = stringResource(R.string.footnote_inviteLink), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + } } } @@ -398,6 +488,7 @@ internal fun GroupInviteSheet( is ShareToChatsSubject.User -> stringResource(R.string.title_shareProfileToChats) is ShareToChatsSubject.Message -> stringResource(R.string.title_shareMessageToChats) is ShareToChatsSubject.Group -> stringResource(R.string.title_invitePeople) + is ShareToChatsSubject.GroupInviteLink -> stringResource(R.string.title_shareInviteLink) }, titleAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(HeaderPadding), @@ -408,7 +499,7 @@ internal fun GroupInviteSheet( // length of the fade. AnimatedVisibility( modifier = Modifier.align(Alignment.BottomCenter), - visible = inviteUrl != null && state.showsComposer, + visible = (inviteUrl != null || inviteLink != null) && state.showsComposer, enter = slideInVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, exit = slideOutVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, ) { @@ -425,7 +516,7 @@ internal fun GroupInviteSheet( hazeState = hazeState, sending = state.sending, sendLabel = stringResource( - if (subject is ShareToChatsSubject.Group) R.string.action_invite else R.string.action_send + if (subject is ShareToChatsSubject.Group || inviteLink != null) R.string.action_invite else R.string.action_send ), onMessageChanged = onMessageChanged, onInvite = onInvite, @@ -447,14 +538,16 @@ private fun InviteLinkTile( onClick: () -> Unit, modifier: Modifier = Modifier, testTag: String? = null, + enabled: Boolean = true, ) { Column( modifier = modifier .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) + .alpha(if (enabled) 1f else DimmedAlpha) .heightIn(min = CodeTheme.dimens.grid.x19) .clip(CodeTheme.shapes.extraSmall) .background(White05) - .clickable(role = Role.Button, onClick = onClick) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1, Alignment.CenterVertically), @@ -474,6 +567,57 @@ private fun InviteLinkTile( } } +/** + * Node 12933:113 — the invite link in a pill, with its expiry and joined count under it; while the + * first tap makes the link, a spinner and "Creating link…" (node 12933:210). The link is cut in the + * middle so the secret is never shown whole. + */ +@Composable +internal fun InviteLinkPill(link: InviteLinkState, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + when (link) { + InviteLinkState.Creating -> { + CodeCircularProgressIndicator(modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6 / 2)) + Text( + text = stringResource(R.string.label_creatingInviteLink), + style = CodeTheme.typography.textMedium, + color = White50, + ) + } + is InviteLinkState.Ready -> Text( + text = elideInviteUrl(link.url), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + // Nothing to show before the first tap makes a link, or while it is fetched. + InviteLinkState.None, InviteLinkState.Loading -> Text( + text = stringResource(R.string.label_inviteLinkPlaceholder), + style = CodeTheme.typography.textMedium, + color = White50, + ) + } + } + if (link is InviteLinkState.Ready) { + Text( + text = inviteMetaLine(link.expiresAt, link.useCount, link.maxUses), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + } +} + /** Node 10330:19568 — the list's label, an ordinary row that scrolls with the chats. */ @Composable private fun RecentChatsHeader() { @@ -503,6 +647,7 @@ private fun RecentChatRow( Row( modifier = Modifier .fillMaxWidth() + .alpha(if (enabled) 1f else DimmedAlpha) .toggleable( value = selected, enabled = enabled, @@ -611,6 +756,9 @@ private val TileRowBottom = 12.dp private val ComposerMinBottom = 8.dp +/** Share, Copy and Recent Chats while the first tap makes the link (node 12933:210). */ +private const val DimmedAlpha = 0.4f + /** How far each edge scrim runs past its bar, as on the Chats list. */ private val ScrimTail = 48.dp // Far enough that the field starts fully below the sheet's bottom edge. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 9e20349caf..2bc55d51d1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.AppRoute @@ -77,7 +78,9 @@ import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.rememberMutedLabel import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar +import com.flipcash.app.messenger.internal.invites.InviteHostViewModel import com.flipcash.app.messenger.internal.screens.openGroupInvite +import com.flipcash.app.messenger.internal.screens.openInviteLinkShare import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatRuleRequirement @@ -124,6 +127,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group val isMember = group?.isMember == true + val inviteHost = hiltViewModel() + val canInviteByChat by inviteHost.canInvite.collectAsStateWithLifecycle() + val canInvite = state.chatId?.let { canInviteByChat[it] } == true + LaunchedEffect(state.chatId, group?.isPrivate) { + // Only a private group has invite links, and the answer is the server's, not the cached chat's. + if (group?.isPrivate == true && isMember) state.chatId?.let(inviteHost::refresh) + } // A private group's non-member has no chat to open: no envelope, no transcript. The profile is // where they ask in, and where they wait. val privateJoin = PrivateGroupJoin.resolve( @@ -246,7 +256,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent( ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE) ) - navigator.openGroupInvite() + // A host sees the three-way sheet (frame 1a); a member without + // can_invite keeps the single join link. + if (canInvite) { + inviteHost.presentHostSheet( + groupName = group?.groupTitle.orEmpty(), + onShareInviteLink = { navigator.openInviteLinkShare() }, + onShareJoinLink = { navigator.openGroupInvite() }, + onManageInvites = { flowNavigator.navigateTo(ChatStep.ManageInvites) }, + ) + } else { + navigator.openGroupInvite() + } }, ) }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt index 2dc07692e6..b690c9530f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.chat.ChatParticipant @@ -34,7 +35,9 @@ import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.invites.InviteHostViewModel import com.flipcash.app.messenger.internal.screens.openGroupInvite +import com.flipcash.app.messenger.internal.screens.openInviteLinkShare import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.RequestAge @@ -64,6 +67,10 @@ internal fun JoinRequestsScreen(viewModel: ChatViewModel) { val flowNavigator = rememberFlowNavigator() val navigator = LocalCodeNavigator.current val group = state.subject as? ChatSubject.Group + val inviteHost = hiltViewModel() + val canInviteByChat by inviteHost.canInvite.collectAsStateWithLifecycle() + val canInvite = state.chatId?.let { canInviteByChat[it] } == true + LaunchedEffect(state.chatId) { state.chatId?.let(inviteHost::refresh) } // LobbyUpdate is best-effort, so the list refetches whenever it comes on screen. LaunchedEffect(Unit) { viewModel.dispatchEvent(ChatViewModel.Event.JoinRequestsOpened) } @@ -91,9 +98,19 @@ internal fun JoinRequestsScreen(viewModel: ChatViewModel) { .fillMaxSize() .padding(innerPadding), groupName = group?.groupTitle.orEmpty(), + canInvite = canInvite, onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE)) - navigator.openGroupInvite() + if (canInvite) { + inviteHost.presentHostSheet( + groupName = group?.groupTitle.orEmpty(), + onShareInviteLink = { navigator.openInviteLinkShare() }, + onShareJoinLink = { navigator.openGroupInvite() }, + onManageInvites = { flowNavigator.navigateTo(ChatStep.ManageInvites) }, + ) + } else { + navigator.openGroupInvite() + } }, ) } else { @@ -219,7 +236,12 @@ private fun JoinRequestRow( } @Composable -private fun NoJoinRequests(groupName: String, onShare: () -> Unit, modifier: Modifier = Modifier) { +private fun NoJoinRequests( + groupName: String, + canInvite: Boolean, + onShare: () -> Unit, + modifier: Modifier = Modifier, +) { Box(modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -241,7 +263,7 @@ private fun NoJoinRequests(groupName: String, onShare: () -> Unit, modifier: Mod ) CodeButton( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.action_shareGroup), + text = stringResource(if (canInvite) R.string.action_shareInviteLinkLong else R.string.action_shareGroup), buttonState = ButtonState.Filled10, shape = CircleShape, style = CodeTheme.typography.textSmall, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt index 0b9a039a0d..9668b66b5f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -70,6 +70,7 @@ class GroupInviteViewModelTest { resources = resources, clipboardManager = mockk(relaxed = true), tipCodePreviewCache = mockk(relaxed = true), + e2eePolicy = com.flipcash.services.chat.E2eePolicy(), ) @Test diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt new file mode 100644 index 0000000000..e7fded3f4c --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt @@ -0,0 +1,200 @@ +package com.flipcash.app.messenger.internal.invites + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.InviteCreation +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.time.Instant + +/** + * The invite variant of the share sheet (frames 1b, 1f, 1c, 1e): nothing is made on open, the + * first Share or Copy tap makes the link, and each way CreateInvite can end maps to its own alert. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class InviteLinkViewModelTest { + + private val chatId = ChatId(ByteArray(32) { 5 }) + private val hostInvites = mockk() + private val resources = mockk(relaxed = true) { + every { getString(any()) } returns "text" + } + private val listed = MutableStateFlow>>(emptyMap()) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + BottomBarManager.clear() + every { hostInvites.state } returns listed + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + every { hostInvites.current(chatId) } returns null + } + + @After + fun tearDown() { + BottomBarManager.clear() + Dispatchers.resetMain() + } + + private fun viewModel() = InviteLinkViewModel(hostInvites, resources, mockk(relaxed = true)) + + private fun hostInvite(url: String?, useCount: Long = 0, maxUses: Long = 0): HostInvite { + val invite = mockk(relaxed = true) { + every { id } returns InviteId(ByteArray(32) { 1 }) + every { expiresAt } returns Instant.fromEpochSeconds(1_800_000_000) + every { this@mockk.useCount } returns useCount + every { this@mockk.maxUses } returns maxUses + } + return HostInvite(invite, url) + } + + @Test + fun `opening makes nothing`() = runTest { + val model = viewModel() + model.open(chatId, null) + + assertEquals(InviteLinkState.None, model.state.value.link) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `an existing current link is shown and shared without creating`() = runTest { + every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a", useCount = 2, maxUses = 10) + val model = viewModel() + model.open(chatId, null) + + val ready = model.state.value.link as InviteLinkState.Ready + assertEquals(2L, ready.useCount) + model.onShare() + + val effect = model.effects.first() as InviteLinkViewModel.Effect.Share + assertEquals("https://x/chat/invite#s=a", effect.url) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `the first tap creates the link and then shares it`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns + InviteCreation.Created(hostInvite("https://x/chat/invite#s=new")) + val model = viewModel() + model.open(chatId, null) + + model.onShare() + + assertTrue(model.state.value.link is InviteLinkState.Ready) + assertEquals("https://x/chat/invite#s=new", (model.effects.first() as InviteLinkViewModel.Effect.Share).url) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + } + + @Test + fun `a second tap while creating does not call create again`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } coAnswers { + kotlinx.coroutines.awaitCancellation() + } + val model = viewModel() + model.open(chatId, null) + + model.onShare() + model.onCopy() + + assertEquals(InviteLinkState.Creating, model.state.value.link) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + } + + @Test + fun `denied shows the unavailable alert and a later tap does not call create again`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable + val model = viewModel() + model.open(chatId, null) + + model.onShare() + assertTrue(model.state.value.unavailable) + assertEquals(1, BottomBarManager.messages.value.size) + assertEquals(1, BottomBarManager.messages.value.single().actions.size) + + BottomBarManager.clear() + model.onCopy() + + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `too many invites shows an alert and leaves the sheet ready to try again`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.TooMany + val model = viewModel() + model.open(chatId, null) + + model.onShare() + + assertEquals(InviteLinkState.None, model.state.value.link) + assertFalse(model.state.value.unavailable) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `any other failure shows the generic error and can be tried again`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.Failed(IllegalStateException()) + val model = viewModel() + model.open(chatId, null) + + model.onShare() + + assertEquals(InviteLinkState.None, model.state.value.link) + assertFalse(model.state.value.unavailable) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `an invite picked in Manage Invites that can't be opened closes the sheet`() = runTest { + val picked = hostInvite(url = null) + listed.value = mapOf(chatId to listOf(picked)) + val model = viewModel() + + model.open(chatId, picked.invite.id.toHex()) + + assertTrue(model.effects.first() is InviteLinkViewModel.Effect.Close) + } + + @Test + fun `a specific invite shares its own link and never creates`() = runTest { + val picked = hostInvite("https://x/chat/invite#s=picked") + listed.value = mapOf(chatId to listOf(picked)) + val model = viewModel() + model.open(chatId, picked.invite.id.toHex()) + + model.onShare() + + assertEquals("https://x/chat/invite#s=picked", (model.effects.first() as InviteLinkViewModel.Effect.Share).url) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `state and effects never print the link`() { + val ready = InviteLinkState.Ready("https://x/chat/invite#s=secret", null, 0, 0) + assertFalse(ready.toString().contains("secret")) + assertFalse(InviteLinkViewModel.Effect.Share("https://x/chat/invite#s=secret").toString().contains("secret")) + assertFalse(InviteLinkViewModel.Effect.Send("https://x/chat/invite#s=secret").toString().contains("secret")) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt new file mode 100644 index 0000000000..34e40d22a6 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt @@ -0,0 +1,171 @@ +package com.flipcash.app.messenger.internal.invites + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.chat.InviteId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.InviteCreation +import com.flipcash.shared.chat.InviteSettings +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.time.Clock + +/** Frames 2a to 2e: load, the undecryptable row, revoke and its DENIED, and create. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ManageInvitesViewModelTest { + + private val chatId = ChatId(ByteArray(32) { 5 }) + private val hostInvites = mockk() + private val resources = mockk(relaxed = true) { + every { getString(any()) } returns "text" + } + private val listed = MutableStateFlow>>(emptyMap()) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + BottomBarManager.clear() + every { hostInvites.state } returns listed + every { hostInvites.current(chatId) } returns null + every { hostInvites.revokeAccess(chatId) } returns Unit + } + + @After + fun tearDown() { + BottomBarManager.clear() + Dispatchers.resetMain() + } + + private fun viewModel() = ManageInvitesViewModel(hostInvites, resources) + + private var seed = 0 + private fun hostInvite( + url: String? = "https://x/chat/invite#s=a", + status: Invite.Status = Invite.Status.ACTIVE, + ): HostInvite { + val n = seed++ + val invite = mockk(relaxed = true) { + every { id } returns InviteId(ByteArray(32) { n.toByte() }) + every { this@mockk.status } returns status + every { createdAt } returns Clock.System.now() + every { expiresAt } returns null + } + return HostInvite(invite, url) + } + + @Test + fun `load puts active links first and marks the current one`() = runTest { + val active = hostInvite() + val revoked = hostInvite(status = Invite.Status.REVOKED) + listed.value = mapOf(chatId to listOf(active, revoked)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active, revoked)) + every { hostInvites.current(chatId) } returns active + + val model = viewModel() + model.open(chatId) + + assertEquals(listOf(active), model.state.value.active) + assertEquals(listOf(revoked), model.state.value.inactive) + assertEquals(active, model.state.value.current) + assertFalse(model.state.value.isEmpty) + } + + @Test + fun `no invites at all is the empty state`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + + val model = viewModel() + model.open(chatId) + + assertTrue(model.state.value.isEmpty) + } + + @Test + fun `a link this device can't open stays in the list without a link`() = runTest { + val locked = hostInvite(url = null) + listed.value = mapOf(chatId to listOf(locked)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(locked)) + + val model = viewModel() + model.open(chatId) + + assertFalse(model.state.value.active.single().canShare) + } + + @Test + fun `a failed load is not an empty list`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(IllegalStateException()) + + val model = viewModel() + model.open(chatId) + + assertTrue(model.state.value.failed) + assertFalse(model.state.value.isEmpty) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `a denied load takes the access away and leaves the screen`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(GetChatInvitesError.Denied()) + + val model = viewModel() + model.open(chatId) + + assertTrue(model.effects.first() is ManageInvitesViewModel.Effect.LostAccess) + verify { hostInvites.revokeAccess(chatId) } + } + + @Test + fun `a denied revoke takes the access away and leaves the screen`() = runTest { + val active = hostInvite() + listed.value = mapOf(chatId to listOf(active)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + coEvery { hostInvites.revoke(chatId, active.invite.id) } returns Result.failure(RevokeInviteError.Denied()) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + BottomBarManager.messages.value.single().actions.single().onClick() + + assertTrue(model.effects.first() is ManageInvitesViewModel.Effect.LostAccess) + verify { hostInvites.revokeAccess(chatId) } + } + + @Test + fun `revoking asks first and a created link closes the new link sheet`() = runTest { + val active = hostInvite() + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + val created = hostInvite() + coEvery { hostInvites.create(chatId, InviteSettings.Default) } returns InviteCreation.Created(created) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + assertTrue(BottomBarManager.messages.value.single().showCancel) + + model.create(InviteSettings.Default) + assertTrue(model.effects.first() is ManageInvitesViewModel.Effect.Created) + assertFalse(model.state.value.creating) + } +} From 9423a1c7923e536cf423f54c13ab6edd5ea1d279 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:41:41 -0400 Subject: [PATCH 08/20] test(messenger): invite flow view models, previews and frame renders Covers create on first tap, each CreateInvite error, the current-link choice, revoke DENIED, the undecryptable row and the encrypted-only Recent Chats. Adds a preview for every frame state and a Robolectric test that renders them to PNGs. --- .../internal/invites/InviteHostViewModel.kt | 37 +-- .../internal/invites/InviteLabels.kt | 2 +- .../internal/invites/InviteLinkViewModel.kt | 35 +-- .../internal/invites/InviteMessages.kt | 93 +++++++ .../internal/invites/InvitePreviews.kt | 230 ++++++++++++++++++ .../invites/ManageInvitesViewModel.kt | 25 +- .../screens/profile/JoinRequestsScreen.kt | 2 +- .../internal/GroupInviteViewModelTest.kt | 20 ++ .../invites/InviteFramesScreenshotTest.kt | 58 +++++ .../internal/invites/InviteLabelsTest.kt | 25 ++ 10 files changed, 439 insertions(+), 88 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabelsTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt index 5d12096221..4909d1fee1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt @@ -2,11 +2,8 @@ package com.flipcash.app.messenger.internal.invites import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flipcash.app.funding.bottomBarCardAction -import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.HostInvites -import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow @@ -34,41 +31,11 @@ internal class InviteHostViewModel @Inject constructor( viewModelScope.launch { hostInvites.refreshAccess(chatId) } } - /** - * Frame 1a, drawn by the bottom bar's card sheet that Group Money and Add Money use, so it - * keeps their spacing, colours and dismissal. - */ + /** Frame 1a: see [showInviteHostSheet]. */ fun presentHostSheet( groupName: String, onShareInviteLink: () -> Unit, onShareJoinLink: () -> Unit, onManageInvites: () -> Unit, - ) { - BottomBarManager.showMessage( - title = resources.getString(R.string.title_inviteToGroup, groupName), - actions = listOf( - bottomBarCardAction( - title = resources.getString(R.string.action_shareInviteLinkLong), - subtitle = resources.getString(R.string.subtitle_shareInviteLink), - iconRes = R.drawable.ic_send_outlined, - testTag = "invite_action_share_invite", - onClick = onShareInviteLink, - ), - bottomBarCardAction( - title = resources.getString(R.string.action_shareJoinLink), - subtitle = resources.getString(R.string.subtitle_shareJoinLink), - iconRes = R.drawable.ic_link_outlined, - testTag = "invite_action_share_join", - onClick = onShareJoinLink, - ), - bottomBarCardAction( - title = resources.getString(R.string.action_manageInvites), - subtitle = resources.getString(R.string.subtitle_manageInvites), - iconRes = R.drawable.ic_checklist, - testTag = "invite_action_manage", - onClick = onManageInvites, - ), - ), - ) - } + ) = showInviteHostSheet(resources, groupName, onShareInviteLink, onShareJoinLink, onManageInvites) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt index 64b06df4c1..67ef995939 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt @@ -78,7 +78,7 @@ internal fun HostInvite.detailLine(): String { } /** The link as the pill shows it: scheme dropped and the middle of the secret elided (node 12933:113). */ -internal fun elideInviteUrl(url: String, head: Int = 37, tail: Int = 4): String { +internal fun elideInviteUrl(url: String, head: Int = 32, tail: Int = 4): String { val bare = url.removePrefix("https://").removePrefix("http://") return if (bare.length <= head + tail + 1) bare else bare.take(head) + "…" + bare.takeLast(tail) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt index 4af8ed69f7..c430ee9505 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt @@ -9,8 +9,6 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.InviteCreation -import com.getcode.manager.BottomBarAction -import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -135,7 +133,7 @@ internal class InviteLinkViewModel @Inject constructor( val chatId = chatId ?: return val current = _state.value when { - current.unavailable -> showUnavailable() + current.unavailable -> showInviteLinksNotAvailable(resources) { _effects.trySend(Effect.OpenJoinLink) } current.link is InviteLinkState.Ready -> perform(action, current.link.url) current.link is InviteLinkState.None && specific == null -> create(chatId, action) } @@ -148,7 +146,7 @@ internal class InviteLinkViewModel @Inject constructor( // has gone through, or the tap would add a second. if (!loaded && !load(chatId)) { _state.update { it.copy(link = InviteLinkState.None) } - showFailed() + showInviteLinkNotCreated(resources) return@launch } val ready = (_state.value.link as? InviteLinkState.Ready) @@ -164,15 +162,15 @@ internal class InviteLinkViewModel @Inject constructor( } InviteCreation.TooMany -> { _state.update { it.copy(link = InviteLinkState.None) } - showTooMany() + showTooManyInviteLinks(resources) } InviteCreation.NotAvailable -> { _state.update { it.copy(link = InviteLinkState.None, unavailable = true) } - showUnavailable() + showInviteLinksNotAvailable(resources) { _effects.trySend(Effect.OpenJoinLink) } } is InviteCreation.Failed -> { _state.update { it.copy(link = InviteLinkState.None) } - showFailed() + showInviteLinkNotCreated(resources) } } } @@ -192,29 +190,6 @@ internal class InviteLinkViewModel @Inject constructor( } } - private fun showTooMany() = BottomBarManager.showAlert( - title = resources.getString(R.string.title_tooManyInviteLinks), - message = resources.getString(R.string.description_tooManyInviteLinks), - ) - - private fun showFailed() = BottomBarManager.showError( - title = resources.getString(R.string.title_inviteLinkNotCreated), - message = resources.getString(R.string.description_inviteLinkNotCreated), - ) - - /** Frame 1e: invite links are not available to this host yet; the plain link still works. */ - private fun showUnavailable() = BottomBarManager.showMessage( - title = resources.getString(R.string.title_inviteLinksNotAvailable), - message = resources.getString(R.string.description_inviteLinksNotAvailable), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_shareGroupLink), - onClick = { _effects.trySend(Effect.OpenJoinLink) }, - ), - ), - showCancel = true, - ) - private fun HostInvite.toState(): InviteLinkState = url?.let { InviteLinkState.Ready(it, invite.expiresAt, invite.useCount, invite.maxUses) } ?: InviteLinkState.None diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt new file mode 100644 index 0000000000..a107cc4863 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt @@ -0,0 +1,93 @@ +package com.flipcash.app.messenger.internal.invites + +import com.flipcash.app.funding.bottomBarCardAction +import com.flipcash.features.messenger.R +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper + +/** + * The bottom-bar messages of the invite flow. They are top-level so the view models and the + * previews draw the same ones. + */ + +/** + * Frame 1a, drawn by the bottom bar's card sheet that Group Money and Add Money use, so it keeps + * their spacing, colours and dismissal. + */ +internal fun showInviteHostSheet( + resources: ResourceHelper, + groupName: String, + onShareInviteLink: () -> Unit, + onShareJoinLink: () -> Unit, + onManageInvites: () -> Unit, +) { + BottomBarManager.showMessage( + title = resources.getString(R.string.title_inviteToGroup, groupName), + actions = listOf( + bottomBarCardAction( + title = resources.getString(R.string.action_shareInviteLinkLong), + subtitle = resources.getString(R.string.subtitle_shareInviteLink), + iconRes = R.drawable.ic_send_outlined, + testTag = "invite_action_share_invite", + onClick = onShareInviteLink, + ), + bottomBarCardAction( + title = resources.getString(R.string.action_shareJoinLink), + subtitle = resources.getString(R.string.subtitle_shareJoinLink), + iconRes = R.drawable.ic_link_outlined, + testTag = "invite_action_share_join", + onClick = onShareJoinLink, + ), + bottomBarCardAction( + title = resources.getString(R.string.action_manageInvites), + subtitle = resources.getString(R.string.subtitle_manageInvites), + iconRes = R.drawable.ic_checklist, + testTag = "invite_action_manage", + onClick = onManageInvites, + ), + ), + ) +} + +/** Frame 1c: the group holds the most active links the server allows. */ +internal fun showTooManyInviteLinks(resources: ResourceHelper) = BottomBarManager.showAlert( + title = resources.getString(R.string.title_tooManyInviteLinks), + message = resources.getString(R.string.description_tooManyInviteLinks), +) + +/** The generic "Couldn't Create Link". */ +internal fun showInviteLinkNotCreated(resources: ResourceHelper) = BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotCreated), + message = resources.getString(R.string.description_inviteLinkNotCreated), +) + +/** Frame 1e: invite links are not available to this host yet; [onShareJoinLink], when given, offers the plain link. */ +internal fun showInviteLinksNotAvailable( + resources: ResourceHelper, + onShareJoinLink: (() -> Unit)? = null, +) = BottomBarManager.showMessage( + title = resources.getString(R.string.title_inviteLinksNotAvailable), + message = resources.getString(R.string.description_inviteLinksNotAvailable), + actions = if (onShareJoinLink != null) { + listOf( + BottomBarAction( + text = resources.getString(R.string.action_shareGroupLink), + onClick = onShareJoinLink, + ), + ) + } else { + listOf(BottomBarAction.Ok) + }, + showCancel = onShareJoinLink != null, +) + +/** Frame 2c: revoking cannot be undone. */ +internal fun showRevokeInviteLink(resources: ResourceHelper, onRevoke: () -> Unit) = BottomBarManager.showAlert( + title = resources.getString(R.string.title_revokeInviteLink), + message = resources.getString(R.string.description_revokeInviteLink), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_revokeLink), onClick = onRevoke), + ), + showCancel = true, +) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt new file mode 100644 index 0000000000..4b10b82b8c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt @@ -0,0 +1,230 @@ +package com.flipcash.app.messenger.internal.invites + +import android.content.Context +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.messenger.internal.screens.GroupInviteSheet +import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject +import com.flipcash.app.messenger.internal.screens.profile.NoJoinRequests +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.manager.BottomBarManager +import com.getcode.ui.components.bars.BottomBarView +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.ResourceType +import java.io.File +import kotlin.time.Instant + +/* + * One preview per frame state of the invite flow. They are internal and parameterless so the + * screenshot test draws the same composables. + */ + +private val PreviewChat = ChatId(ByteArray(32) { 3 }) +private val Group = ChatSubject.Group( + chatId = PreviewChat, + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = null, + isMember = true, +) +private const val Url = "https://app.flipcash.com/chat/invite#s=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEf" +private val Expiry = Instant.fromEpochSeconds(1_794_000_000) + +private val RecentChats = listOf( + ConversationReference(chatId = ChatId(ByteArray(32) { 1 }), title = "Bad Boys 2", isGroup = true), + ConversationReference(chatId = ChatId(ByteArray(32) { 2 }), displayName = "Dana"), + ConversationReference(chatId = ChatId(ByteArray(32) { 4 }), title = "Coffee Crew", isGroup = true), +) + +private fun invite( + n: Int, + status: Invite.Status = Invite.Status.ACTIVE, + useCount: Long = 0, + maxUses: Long = 0, + expiresAt: Instant? = Expiry, + url: String? = Url, +) = HostInvite( + Invite( + id = InviteId(ByteArray(32) { n.toByte() }), + chatId = PreviewChat, + createdBy = List(32) { 1 }, + maxUses = maxUses, + useCount = useCount, + createdAt = Instant.fromEpochSeconds(1_790_000_000L - n * 86_400L), + expiresAt = expiresAt, + status = status, + secretEnvelope = null, + ), + url, +) + +private val Active = invite(1, useCount = 3, maxUses = 10) +private val Inactive = listOf( + invite(2, status = Invite.Status.EXPIRED), + invite(3, status = Invite.Status.REVOKED, expiresAt = null), +) + +@Composable +private fun rememberPreviewResources(): ResourceHelper { + val context = LocalContext.current + return remember(context) { PreviewResources(context) } +} + +/** Draws whatever [show] puts on the bottom bar, as the app's host does. */ +@Composable +private fun BottomBarFrame(show: (ResourceHelper) -> Unit) { + val resources = rememberPreviewResources() + val message = remember { + BottomBarManager.clear() + show(resources) + BottomBarManager.messages.value.last().also { BottomBarManager.clear() } + } + BottomBarView(bottomBarMessage = message, onShown = {}, onClose = {}, onBackPressed = {}) +} + +@Composable +private fun InviteSheetFrame(subject: ShareToChatsSubject, selection: List = emptyList()) { + GroupInviteSheet( + inviteUrl = if (subject is ShareToChatsSubject.Group) Url.removePrefix("https://app.flipcash.com/chat/invite#s=") else null, + subject = subject, + state = GroupInviteViewModel.State( + recentChats = RecentChats, + selection = selection, + encryptedOnly = subject is ShareToChatsSubject.GroupInviteLink, + encryptedChats = RecentChats.map { it.chatId }.toSet(), + ), + onShare = {}, + onCopy = {}, + onToggle = {}, + onMessageChanged = {}, + onInvite = {}, + onDismiss = {}, + onShowCard = if (subject is ShareToChatsSubject.Group) ({}) else null, + ) +} + +/** Frame 1a, node 12844:60. */ +@Preview(heightDp = 520) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1aHostSheet() = BottomBarFrame { + showInviteHostSheet(it, "Bad Boys", {}, {}, {}) +} + +/** Frame 1b, node 12933:210: the first tap is making the link. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1bCreating() = + InviteSheetFrame(ShareToChatsSubject.GroupInviteLink(Group, InviteLinkState.Creating)) + +/** Frame 1c, node 12844:161. */ +@Preview(heightDp = 400) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1cTooMany() = BottomBarFrame { showTooManyInviteLinks(it) } + +/** Frame 1d, node 12844:185: Join Requests' empty state for a viewer who can invite. */ +@Preview(heightDp = 600) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1dJoinRequestsEmpty() = + NoJoinRequests(groupName = "Bad Boys", canInvite = true, onShare = {}) + +/** Frame 1e, node 12855:117. */ +@Preview(heightDp = 400) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1eNotAvailable() = BottomBarFrame { showInviteLinksNotAvailable(it) {} } + +/** Frame 1f, node 12933:113: the invite variant with a link. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1fReady() = InviteSheetFrame( + ShareToChatsSubject.GroupInviteLink(Group, InviteLinkState.Ready(Url, Expiry, 0, 0)), +) + +/** Frame 1g, node 12933:313: the join variant, unchanged. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1gJoin() = InviteSheetFrame(ShareToChatsSubject.Group(Group)) + +/** Frame 2a, node 12844:200. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2aManage() = ManageInvitesContent( + state = ManageInvitesViewModel.State( + loading = false, + active = listOf(Active, invite(4, url = null)), + inactive = Inactive, + current = Active, + ), + onBack = {}, + onRow = {}, + onNewLink = {}, +) + +/** Frame 2b, node 12886:56. */ +@Preview(heightDp = 300) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2bRowSheet() = + InviteLinkActions(invite = Active, onShare = {}, onRevoke = {}, onDismiss = {}) + +/** Frame 2c, node 12844:288. */ +@Preview(heightDp = 400) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2cRevoke() = BottomBarFrame { showRevokeInviteLink(it) {} } + +/** Frame 2d, node 12844:334. */ +@Preview(heightDp = 560) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2dNewLink() = + NewInviteLinkContent(creating = false, onCreate = {}, initial = InviteSettings.Default) + +/** Frame 2e, node 12844:395. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2eEmpty() = ManageInvitesContent( + state = ManageInvitesViewModel.State(loading = false), + onBack = {}, + onRow = {}, + onNewLink = {}, +) + +/** Strings only: a preview has no injected resources. */ +private class PreviewResources(private val context: Context) : ResourceHelper { + override fun getString(resourceId: Int): String = context.getString(resourceId) + override fun getString(resourceId: Int, vararg formatArgs: Any): String = + context.getString(resourceId, *formatArgs) + override fun getRawResource(resourceId: Int): String = "" + override fun getQuantityString(id: Int, quantity: Int, vararg formatArgs: Any, default: String): String = default + override fun getDimension(dimenId: Int, default: Float): Float = default + override fun getDimensionPixelSize(dimenId: Int, default: Int): Int = default + override fun getDir(name: String, mode: Int): File? = null + override val displayMetrics: DisplayMetrics get() = context.resources.displayMetrics + override fun getDrawable(drawableResId: Int): Drawable? = null + override fun getIdentifier(name: String, type: ResourceType): Int? = null + override fun getFont(fontResId: Int): Typeface? = null +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt index 4d2c925839..b8021b8162 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt @@ -9,7 +9,6 @@ import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess import com.flipcash.shared.chat.InviteCreation import com.flipcash.shared.chat.InviteSettings -import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel @@ -88,14 +87,7 @@ internal class ManageInvitesViewModel @Inject constructor( /** Asks first, because revoking cannot be undone (frame 2c). */ fun confirmRevoke(invite: HostInvite) { - BottomBarManager.showAlert( - title = resources.getString(R.string.title_revokeInviteLink), - message = resources.getString(R.string.description_revokeInviteLink), - actions = listOf( - BottomBarAction(text = resources.getString(R.string.action_revokeLink)) { revoke(invite) }, - ), - showCancel = true, - ) + showRevokeInviteLink(resources) { revoke(invite) } } private fun revoke(invite: HostInvite) { @@ -130,24 +122,15 @@ internal class ManageInvitesViewModel @Inject constructor( } InviteCreation.TooMany -> { _state.update { it.copy(creating = false) } - BottomBarManager.showAlert( - title = resources.getString(R.string.title_tooManyInviteLinks), - message = resources.getString(R.string.description_tooManyInviteLinks), - ) + showTooManyInviteLinks(resources) } InviteCreation.NotAvailable -> { _state.update { it.copy(creating = false) } - BottomBarManager.showMessage( - title = resources.getString(R.string.title_inviteLinksNotAvailable), - message = resources.getString(R.string.description_inviteLinksNotAvailable), - ) + showInviteLinksNotAvailable(resources) } is InviteCreation.Failed -> { _state.update { it.copy(creating = false) } - BottomBarManager.showError( - title = resources.getString(R.string.title_inviteLinkNotCreated), - message = resources.getString(R.string.description_inviteLinkNotCreated), - ) + showInviteLinkNotCreated(resources) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt index b690c9530f..4c480c630b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt @@ -236,7 +236,7 @@ private fun JoinRequestRow( } @Composable -private fun NoJoinRequests( +internal fun NoJoinRequests( groupName: String, canInvite: Boolean, onShare: () -> Unit, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt index 9668b66b5f..f41b04e90d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -188,6 +188,26 @@ class GroupInviteViewModelTest { assertEquals(emptyList(), model.state.value.selection) } + @Test + fun `an invite link is only offered to private groups`() = runTest(scheduler) { + val privateGroup = ChatId(ByteArray(32) { 8 }) + val publicGroup = ChatId(ByteArray(32) { 9 }) + every { chatCoordinator.feed(*anyVararg()) } returns flowOf( + listOf( + summary(privateGroup, ChatType.GROUP).let { it.copy(metadata = it.metadata.copy(isPrivate = true)) }, + summary(publicGroup, ChatType.GROUP), + ), + ) + val model = viewModel() + advanceUntilIdle() + + model.restrictToEncrypted(true) + assertEquals(listOf(privateGroup), model.state.value.invitable?.map { it.chatId }) + + model.restrictToEncrypted(false) + assertEquals(listOf(privateGroup, publicGroup), model.state.value.invitable?.map { it.chatId }) + } + @Test fun `a person's DM is left out of the chats their profile is sent to`() = runTest(scheduler) { val dm = ChatId(ByteArray(32) { 7 }) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt new file mode 100644 index 0000000000..6e9d3cf141 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt @@ -0,0 +1,58 @@ +package com.flipcash.app.messenger.internal.invites + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.flipcash.app.theme.FlipcashPreview +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders every invite frame's preview to `build/screenshots/invite_.png`, to be compared + * with the design by eye. Not an assertion test; it fails only if a frame throws. + * + * Same mechanics as the group chrome screenshot test: pause the clock, pump a fixed number of + * frames and draw the view directly, so a composable that keeps scheduling frames can't hang. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [32], qualifiers = "w411dp-h891dp-xxhdpi") +class InviteFramesScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun render(name: String, content: @Composable () -> Unit) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { FlipcashPreview(showBackground = true) { content() } } + repeat(12) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val file = File(File("build/screenshots").apply { mkdirs() }, "invite_$name.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath}") + } + + @Test fun frame1a() = render("1a") { InviteFrame1aHostSheet() } + @Test fun frame1b() = render("1b") { InviteFrame1bCreating() } + @Test fun frame1c() = render("1c") { InviteFrame1cTooMany() } + @Test fun frame1d() = render("1d") { InviteFrame1dJoinRequestsEmpty() } + @Test fun frame1e() = render("1e") { InviteFrame1eNotAvailable() } + @Test fun frame1f() = render("1f") { InviteFrame1fReady() } + @Test fun frame1g() = render("1g") { InviteFrame1gJoin() } + @Test fun frame2a() = render("2a") { InviteFrame2aManage() } + @Test fun frame2b() = render("2b") { InviteFrame2bRowSheet() } + @Test fun frame2c() = render("2c") { InviteFrame2cRevoke() } + @Test fun frame2d() = render("2d") { InviteFrame2dNewLink() } + @Test fun frame2e() = render("2e") { InviteFrame2eEmpty() } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabelsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabelsTest.kt new file mode 100644 index 0000000000..988d7deaf8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabelsTest.kt @@ -0,0 +1,25 @@ +package com.flipcash.app.messenger.internal.invites + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InviteLabelsTest { + + @Test + fun `the pill drops the scheme and elides the middle of the secret`() { + val url = "https://app.flipcash.com/chat/invite#s=" + "A".repeat(10) + "B".repeat(30) + "wxyz" + + val shown = elideInviteUrl(url) + + assertTrue(shown.startsWith("app.flipcash.com/chat/invite#s=")) + assertTrue(shown.endsWith("…wxyz")) + assertFalse(shown.contains("B".repeat(30))) + } + + @Test + fun `a short link is shown whole`() { + assertEquals("a.com/x", elideInviteUrl("https://a.com/x")) + } +} From eed27226883be3deefdfb502ea8c8b392b7ad418 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:56:28 -0400 Subject: [PATCH 09/20] fix(messenger): title the invite-link share sheet Invite People The invite variant read Share Invite Link while the join variant read Invite People. Both now use title_invitePeople. The send button already reads Invite on both variants and is unchanged. --- .../app/messenger/internal/screens/GroupInviteSheet.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 7563da18b4..1fc5047821 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -487,8 +487,8 @@ internal fun GroupInviteSheet( stringResource(R.string.title_shareCurrency, subject.name) is ShareToChatsSubject.User -> stringResource(R.string.title_shareProfileToChats) is ShareToChatsSubject.Message -> stringResource(R.string.title_shareMessageToChats) - is ShareToChatsSubject.Group -> stringResource(R.string.title_invitePeople) - is ShareToChatsSubject.GroupInviteLink -> stringResource(R.string.title_shareInviteLink) + is ShareToChatsSubject.Group, + is ShareToChatsSubject.GroupInviteLink -> stringResource(R.string.title_invitePeople) }, titleAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(HeaderPadding), From 73e337a4fb8982bcc19b05fca75ac257e65092dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:56:38 -0400 Subject: [PATCH 10/20] fix(messenger): use the spec's use-limit presets on New Link Who can join offered No limit, 1, 10 and 50, which is the Figma frame. The spec lists 1, 5, 10, 25 and No limit; the chips now follow it, and No limit stays the default. Also drops title_shareInviteLink, unused since the title change. --- apps/flipcash/core/src/main/res/values/strings.xml | 6 +++--- .../app/messenger/internal/invites/ManageInvitesScreen.kt | 5 +++-- .../main/kotlin/com/flipcash/shared/chat/HostInvites.kt | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index ee5486213a..e2d355f5eb 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1488,7 +1488,6 @@ People ask to join and you approve each one Manage Invites See and revoke the invite links you\'ve made - Share Invite Link Creating link… Your link is made when you share or copy it Share Link @@ -1521,10 +1520,11 @@ 7 days 30 days Never - No limit 1 person + 5 10 - 50 + 25 + No limit Create Link Too Many Active Links This group has as many active invite links as it can have. Revoke one in Manage Invites to make another diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt index 81e427e4f0..4c5b86170d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt @@ -484,10 +484,11 @@ private fun InviteExpiry.labelRes(): Int = when (this) { } private fun InviteUses.labelRes(): Int = when (this) { - InviteUses.NoLimit -> R.string.label_inviteUses_noLimit InviteUses.One -> R.string.label_inviteUses_one + InviteUses.Five -> R.string.label_inviteUses_five InviteUses.Ten -> R.string.label_inviteUses_ten - InviteUses.Fifty -> R.string.label_inviteUses_fifty + InviteUses.TwentyFive -> R.string.label_inviteUses_twentyFive + InviteUses.NoLimit -> R.string.label_inviteUses_noLimit } /** Inactive rows are greyed (frame 2a). */ diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt index 4428131f3d..61dd91e18d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -39,12 +39,13 @@ enum class InviteExpiry(val duration: Duration?) { Never(null), } -/** New Invite Link's Who can join presets (node 12844:334). 0 is no limit. */ +/** New Invite Link's Who can join presets, in the spec's order (group-invites.md). 0 is no limit. */ enum class InviteUses(val maxUses: Int) { - NoLimit(0), One(1), + Five(5), Ten(10), - Fifty(50), + TwentyFive(25), + NoLimit(0), } data class InviteSettings(val expiry: InviteExpiry, val uses: InviteUses) { From ebcadeac6689a2ad9df9fcb229b4d76469dddadc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:02:47 -0400 Subject: [PATCH 11/20] fix(chat): clamp a refused invite expiry to the next shorter preset The spec (group-invites.md, Surfaces 1 step 4) retries INVALID_EXPIRES_AT once at the next shorter preset and only then shows the generic error. Restores the behaviour db579ca75 removed. --- .../com/flipcash/shared/chat/HostInvites.kt | 27 ++++++++++++++++--- .../flipcash/shared/chat/HostInvitesTest.kt | 17 ++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt index 61dd91e18d..51527a0b09 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -37,6 +37,16 @@ enum class InviteExpiry(val duration: Duration?) { SevenDays(7.days), ThirtyDays(30.days), Never(null), + ; + + /** The next shorter preset, for a server that refuses this one's `expires_at`. */ + val shorter: InviteExpiry? + get() = when (this) { + Never -> ThirtyDays + ThirtyDays -> SevenDays + SevenDays -> OneDay + OneDay -> null + } } /** New Invite Link's Who can join presets, in the spec's order (group-invites.md). 0 is no limit. */ @@ -169,16 +179,17 @@ class HostInvites internal constructor( /** * Makes an invite with [settings]. If the server answers OK with an invite that is no longer * ACTIVE, which only a reused token can cause, the secret is dropped and one new attempt made. - * Every other refusal, `INVALID_EXPIRES_AT` included, ends the attempt: the presets all fit the - * server's limits, so a refused expiry is a bug rather than something to retry around. + * A refused `expires_at` is retried once at the next shorter preset. */ suspend fun create(chatId: ChatId, settings: InviteSettings): InviteCreation = createLock.withLock { createLocked(chatId, settings) } private suspend fun createLocked(chatId: ChatId, settings: InviteSettings): InviteCreation { + var attempt = settings + var clamped = false var renewed = false while (true) { - when (val result = attemptCreate(chatId, settings)) { + when (val result = attemptCreate(chatId, attempt)) { is AttemptResult.Created -> { val created = HostInvite(result.invite, result.url) invites.update { all -> @@ -191,6 +202,14 @@ class HostInvites internal constructor( if (renewed) return InviteCreation.Failed(IllegalStateException("Created invite is not active")) renewed = true } + AttemptResult.ExpiryRefused -> { + val shorter = attempt.expiry.shorter + if (clamped || shorter == null) { + return InviteCreation.Failed(CreateInviteError.InvalidExpiresAt()) + } + clamped = true + attempt = attempt.copy(expiry = shorter) + } is AttemptResult.Done -> return result.creation } } @@ -221,6 +240,7 @@ class HostInvites internal constructor( private sealed interface AttemptResult { class Created(val invite: Invite, val url: String) : AttemptResult data object NotActive : AttemptResult + data object ExpiryRefused : AttemptResult class Done(val creation: InviteCreation) : AttemptResult } @@ -257,6 +277,7 @@ class HostInvites internal constructor( return when (error) { is CreateInviteError.TooManyInvites -> AttemptResult.Done(InviteCreation.TooMany) is CreateInviteError.Denied -> AttemptResult.Done(InviteCreation.NotAvailable) + is CreateInviteError.InvalidExpiresAt -> AttemptResult.ExpiryRefused else -> AttemptResult.Done(InviteCreation.Failed(error)) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt index 9bbb3863b3..d82410bd43 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt @@ -191,15 +191,16 @@ class HostInvitesTest { } @Test - fun `a refused expiry fails without retry`() = runTest { - coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns - Result.failure(CreateInviteError.InvalidExpiresAt()) + fun `a refused expiry is retried once at the next shorter preset`() = runTest { + val expiries = mutableListOf() + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), captureNullable(expiries)) } answers { + if (expiries.size == 1) Result.failure(CreateInviteError.InvalidExpiresAt()) + else Result.success(invite(cipher.last)) + } - val failed = assertIs( - subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) - ) - assertIs(failed.cause) - coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) + + assertEquals(listOf(null, now + 30.days), expiries) } @Test From e9058e9d828bcb257b7c52dd7460b4dc3678db61 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:06:54 -0400 Subject: [PATCH 12/20] fix(chat): rethrow cancellation and serialize invite create against clear load, attemptCreate and revoke no longer turn a cancelled caller into a failure result. currentOrCreate re-checks the current link under the create lock so two waiting callers make one link. clear() bumps an epoch, so an in-flight load or create for the old account cannot write into the next one's state, and the pending-secret map is concurrent. Adds HostInvites tests for unopenable envelopes, reuse, duplicate ids, clear between retries, serialized creates, load and revoke failures, the lost-access truth table and cancellation. --- .../com/flipcash/shared/chat/HostInvites.kt | 59 +++-- .../flipcash/shared/chat/HostInvitesTest.kt | 210 ++++++++++++++++++ 2 files changed, 255 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt index 51527a0b09..67eff5d5a2 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -18,12 +18,14 @@ import com.flipcash.services.models.chat.ViewMode import com.getcode.chatcipher.InviteCipher import com.getcode.chatcipher.InviteLink import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock @@ -132,9 +134,15 @@ class HostInvites internal constructor( // One secret per create attempt, kept until the server answers for certain, so a retry after a // dropped response sends the same token and the server hands back the first invite rather // than making a second. - private val pending = mutableMapOf, ByteArray>() + // Concurrent because clear() can run (logout) while an attempt is in flight. + private val pending = ConcurrentHashMap, ByteArray>() private val createLock = Mutex() + // Bumped by clear(), so a load or create that was in flight for the old account does not + // write its answer into the next one's state. + @Volatile + private var epoch = 0 + /** Reads [chatId]'s `can_invite` from the server into [canInvite]. A failure leaves it as it was. */ suspend fun refreshAccess(chatId: ChatId): Result = chatController.getChat(chatId, ViewMode.FULL_OR_REDACTED) @@ -147,7 +155,14 @@ class HostInvites internal constructor( } /** Fetches every page of [chatId]'s invites and replaces what was held for it. */ - suspend fun load(chatId: ChatId): Result> = runCatching { + suspend fun load(chatId: ChatId): Result> { + val started = epoch + return catching { loadAll(chatId) }.onSuccess { list -> + if (started == epoch) invites.update { it + (chatId to list) } + } + } + + private suspend fun loadAll(chatId: ChatId): List { val all = mutableListOf() var options = QueryOptions() do { @@ -155,26 +170,26 @@ class HostInvites internal constructor( all += page.invites options = options.copy(token = page.pagingToken) } while (page.hasMore && page.pagingToken != null) - all.distinctBy { it.id } + return all.distinctBy { it.id } .sortedByDescending { it.createdAt } .map { HostInvite(it, linkFor(it)) } - }.onSuccess { list -> - invites.update { it + (chatId to list) } } /** * The link the sheet shares: the newest ACTIVE invite the viewer created whose link this - * device can rebuild. Null when there is none, and the sheet makes one on first Share or Copy. + * device can rebuild. Null when there is none, and tapping Share Invite Link makes one. */ fun current(chatId: ChatId): HostInvite? = currentOf(invites.value[chatId].orEmpty(), keys.ownUserId()) /** - * The link the sheet shares, made when there is none: the first Share or Copy tap (spec - * Decision 9). The new link is 7 days with no use limit. [load] has to have run first, or a - * link that exists but was never fetched is not seen and a second one is made. + * The link the sheet shares, made when there is none: the tap on Share Invite Link (spec + * Decision 9, Open 9). The new link is 7 days with no use limit. [load] has to have run first, + * or a link that exists but was never fetched is not seen and a second one is made. The check + * runs again under the create lock, so two callers waiting on it make one link between them. */ - suspend fun currentOrCreate(chatId: ChatId): InviteCreation = - current(chatId)?.let { InviteCreation.Created(it) } ?: create(chatId, InviteSettings.Default) + suspend fun currentOrCreate(chatId: ChatId): InviteCreation = createLock.withLock { + current(chatId)?.let { InviteCreation.Created(it) } ?: createLocked(chatId, InviteSettings.Default) + } /** * Makes an invite with [settings]. If the server answers OK with an invite that is no longer @@ -185,6 +200,7 @@ class HostInvites internal constructor( createLock.withLock { createLocked(chatId, settings) } private suspend fun createLocked(chatId: ChatId, settings: InviteSettings): InviteCreation { + val started = epoch var attempt = settings var clamped = false var renewed = false @@ -192,6 +208,7 @@ class HostInvites internal constructor( when (val result = attemptCreate(chatId, attempt)) { is AttemptResult.Created -> { val created = HostInvite(result.invite, result.url) + if (started != epoch) return InviteCreation.Created(created) invites.update { all -> val others = all[chatId].orEmpty().filterNot { it.invite.id == created.invite.id } all + (chatId to listOf(created) + others) @@ -217,7 +234,7 @@ class HostInvites internal constructor( /** Revokes [inviteId]. Revoking an invite already revoked is OK, so a retry is safe. */ suspend fun revoke(chatId: ChatId, inviteId: InviteId): Result = - chatController.revokeInvite(chatId, inviteId).onSuccess { + chatController.revokeInvite(chatId, inviteId).onFailure { if (it is CancellationException) throw it }.onSuccess { invites.update { all -> val list = all[chatId] ?: return@update all all + (chatId to list.map { item -> @@ -232,6 +249,7 @@ class HostInvites internal constructor( /** Drops everything held. For logout: the invites belong to the account. */ fun clear() { + epoch++ invites.value = emptyMap() access.value = emptyMap() pending.clear() @@ -247,11 +265,14 @@ class HostInvites internal constructor( private suspend fun attemptCreate(chatId: ChatId, settings: InviteSettings): AttemptResult { val own = keys.ownKeyPair() ?: return AttemptResult.Done(InviteCreation.Failed(IllegalStateException("No account key pair"))) - val chatKey = groupKeys.key(chatId).getOrElse { return AttemptResult.Done(InviteCreation.Failed(it)) } + val chatKey = groupKeys.key(chatId).getOrElse { + if (it is CancellationException) throw it + return AttemptResult.Done(InviteCreation.Failed(it)) + } val slot = chatId to settings val secret = pending.getOrPut(slot) { cipher.newSecret() } - val sealed = runCatching { + val sealed = catching { val inviteId = cipher.inviteId(secret) Triple( InviteToken(cipher.inviteToken(secret)), @@ -273,6 +294,7 @@ class HostInvites internal constructor( expiresAt = settings.expiry.duration?.let { clock.now() + it }, ) val invite = result.getOrElse { error -> + if (error is CancellationException) throw error if (error.isAnswered()) pending.remove(slot) return when (error) { is CreateInviteError.TooManyInvites -> AttemptResult.Done(InviteCreation.TooMany) @@ -286,6 +308,15 @@ class HostInvites internal constructor( return AttemptResult.Created(invite, InviteLink.url(secret)) } + // runCatching that lets a cancelled caller cancel instead of reporting it as a failure. + private inline fun catching(block: () -> T): Result = try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } + private fun linkFor(invite: Invite): String? { val own = keys.ownKeyPair() ?: return null val payload = invite.secretEnvelope?.toPayload() ?: return null diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt index d82410bd43..c73d7bc47d 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt @@ -6,6 +6,10 @@ import com.flipcash.services.chat.GroupKeySource import com.flipcash.services.chat.toInviteSecretEnvelope import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata @@ -22,12 +26,19 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import io.mockk.slot +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import java.io.IOException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock @@ -203,6 +214,205 @@ class HostInvitesTest { assertEquals(listOf(null, now + 30.days), expiries) } + @Test + fun `a refused expiry that is refused again fails with the expiry error`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.InvalidExpiresAt()) + + val failed = assertIs( + subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) + ) + assertIs(failed.cause) + coVerify(exactly = 2) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `the shortest expiry has nothing shorter to retry at`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.InvalidExpiresAt()) + + assertIs(subject.create(chatId, InviteSettings(InviteExpiry.OneDay, InviteUses.NoLimit))) + coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `an envelope that opens to another invite's secret gives no link`() = runTest { + val real = invite(secret(1)) + // Sealed under this invite's id but holding a different secret, so it opens and then fails the id check. + val id = real.id.bytes + val forged = real.copy( + secretEnvelope = cipher.sealInviteSecretEnvelope(ownKeys, secret(2), chatId.bytes, id) + .toInviteSecretEnvelope(), + ) + coEvery { chatController.getChatInvites(chatId, any()) } returns + Result.success(InvitesPage(listOf(forged), null, false)) + + val item = subject.load(chatId).getOrThrow().single() + + assertNull(item.url) + assertFalse(item.canShare) + assertNull(subject.current(chatId)) + } + + @Test + fun `an envelope with an unsupported scheme gives no link`() = runTest { + val real = invite(secret(1)) + val old = real.secretEnvelope!! + val odd = real.copy(secretEnvelope = InviteSecretEnvelope(99, old.nonce, old.ciphertext)) + coEvery { chatController.getChatInvites(chatId, any()) } returns + Result.success(InvitesPage(listOf(odd), null, false)) + + val item = subject.load(chatId).getOrThrow().single() + + assertNull(item.url) + assertFalse(item.canShare) + assertNull(subject.current(chatId)) + } + + @Test + fun `currentOrCreate reuses an existing link without calling createInvite`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.success(InvitesPage(listOf(mine), null, false)) + subject.load(chatId) + + val result = assertIs(subject.currentOrCreate(chatId)) + + assertEquals(mine.id, result.invite.invite.id) + coVerify(exactly = 0) { chatController.createInvite(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `two currentOrCreate calls at once make one link`() = runTest { + val gate = CompletableDeferred() + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } coAnswers { + gate.await() + Result.success(invite(cipher.last)) + } + + val both = (1..2).map { async { subject.currentOrCreate(chatId) } } + runCurrent() + gate.complete(Unit) + val results = both.awaitAll() + + assertTrue(results.all { it is InviteCreation.Created }) + coVerify(exactly = 1) { chatController.createInvite(any(), any(), any(), any(), any(), any()) } + assertEquals(1, subject.state.value[chatId]?.size) + } + + @Test + fun `concurrent creates run one after the other`() = runTest { + val gate = CompletableDeferred() + var inFlight = 0 + var peak = 0 + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } coAnswers { + inFlight++ + peak = maxOf(peak, inFlight) + gate.await() + inFlight-- + Result.success(invite(cipher.last)) + } + + val both = (1..2).map { async { subject.create(chatId, InviteSettings.Default) } } + runCurrent() + assertEquals(1, inFlight) + gate.complete(Unit) + both.awaitAll() + + assertEquals(1, peak) + coVerify(exactly = 2) { chatController.createInvite(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `the server returning the same invite twice adds one row`() = runTest { + val same = invite(secret(5)) + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns Result.success(same) + + subject.create(chatId, InviteSettings.Default) + subject.create(chatId, InviteSettings.Default) + + assertEquals(listOf(same.id), subject.state.value[chatId]?.map { it.invite.id }) + } + + @Test + fun `clear between a failure and a retry makes a new secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } returnsMany listOf( + Result.failure(CreateInviteError.Other(IOException("dropped"))), + Result.success(invite(secret(0))), + ) + + subject.create(chatId, InviteSettings.Default) + subject.clear() + subject.create(chatId, InviteSettings.Default) + + assertNotEquals(tokens[0], tokens[1]) + } + + @Test + fun `a revoke failure leaves the invite as it was`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.success(InvitesPage(listOf(mine), null, false)) + coEvery { chatController.revokeInvite(chatId, mine.id) } returns Result.failure(RevokeInviteError.Other(IOException())) + subject.load(chatId) + + assertTrue(subject.revoke(chatId, mine.id).isFailure) + + assertEquals(Invite.Status.ACTIVE, subject.state.value[chatId]?.single()?.invite?.status) + assertEquals(mine.id, subject.current(chatId)?.invite?.id) + } + + @Test + fun `a load failure keeps what was loaded before`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returnsMany listOf( + Result.success(InvitesPage(listOf(mine), null, false)), + Result.failure(GetChatInvitesError.Other(IOException())), + ) + subject.load(chatId) + + assertTrue(subject.load(chatId).isFailure) + + assertEquals(listOf(mine.id), subject.state.value[chatId]?.map { it.invite.id }) + } + + @Test + fun `only a denied list or revoke means the viewer lost access`() { + assertTrue(GetChatInvitesError.Denied().isLostInviteAccess()) + assertTrue(RevokeInviteError.Denied().isLostInviteAccess()) + assertFalse(CreateInviteError.Denied().isLostInviteAccess()) + assertFalse(GetChatInvitesError.NotFound().isLostInviteAccess()) + assertFalse(RevokeInviteError.NotFound().isLostInviteAccess()) + assertFalse(GetChatInvitesError.Other(IOException()).isLostInviteAccess()) + assertFalse(IOException().isLostInviteAccess()) + } + + @Test + fun `a cancelled load throws instead of returning a failure`() = runTest { + coEvery { chatController.getChatInvites(chatId, any()) } throws CancellationException("cancelled") + + assertFailsWith { subject.load(chatId) } + assertNull(subject.state.value[chatId]) + } + + @Test + fun `a cancelled load result throws too`() = runTest { + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.failure(CancellationException("cancelled")) + + assertFailsWith { subject.load(chatId) } + } + + @Test + fun `a cancelled create is not reported as a failure and keeps its secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } throws + CancellationException("cancelled") andThen Result.success(invite(secret(0))) + + assertFailsWith { subject.create(chatId, InviteSettings.Default) } + assertIs(subject.create(chatId, InviteSettings.Default)) + + assertEquals(tokens[0], tokens[1]) + } + @Test fun `revoke marks the invite revoked so it is no longer current`() = runTest { val mine = invite(secret(1)) From adcbf4ad4a8b1c4dff097884ecab3c04de4a45a6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:12:27 -0400 Subject: [PATCH 13/20] fix(core): mark copied invite links as sensitive on the clipboard Adds setSensitiveText, which sets EXTRA_IS_SENSITIVE on the clip so Android hides the link from the clipboard preview. setText is unchanged for other callers. --- .../flipcash/app/core/extensions/Clipboard.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt index be9224729b..d73b069e92 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt @@ -1,8 +1,30 @@ package com.flipcash.app.core.extensions import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager +import android.os.Build +import android.os.PersistableBundle fun ClipboardManager.setText(text: CharSequence, label: String) { setPrimaryClip(ClipData.newPlainText(label, text)) -} \ No newline at end of file +} + +/** + * Copies [text] and marks the clip sensitive, so Android 13+ hides it from the clipboard preview + * and older Android keeps it out of keyboard suggestions that honour the flag. For a value that + * is a secret, such as an invite link, which is as good as the chat key. + */ +fun ClipboardManager.setSensitiveText(text: CharSequence, label: String) { + val clip = ClipData.newPlainText(label, text) + clip.description.extras = PersistableBundle().apply { putBoolean(SensitiveClipKey, true) } + setPrimaryClip(clip) +} + +/** [ClipDescription.EXTRA_IS_SENSITIVE], which the platform only declares from API 33. */ +val SensitiveClipKey: String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } From 5115a70bb9ce69107e5e21e47eac75988aea0203 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:12:27 -0400 Subject: [PATCH 14/20] fix(messenger): create the invite link on the Share Invite Link tap The sheet now opens in the creating state and creates at once when the host has no current link; a specific invite from Manage Invites never creates. Share, Copy and Send tiles stay disabled until the link is ready. Too Many Active Links offers Manage Invites and Cancel, errors close the sheet afterwards, and a lost invite permission closes it as Manage Invites does. Manage Invites shows a spinner on first load and a Retry on failure, inactive rows show status and dates only, an unknown status reads as unavailable, and the use-limit chips are radio buttons at least 48dp tall. --- .../core/src/main/res/values/strings.xml | 5 +- .../flipcash/app/messenger/ChatFlowScreen.kt | 10 +- .../internal/invites/InviteLabels.kt | 21 ++- .../internal/invites/InviteLinkViewModel.kt | 172 ++++++++++-------- .../internal/invites/InviteMessages.kt | 46 ++++- .../internal/invites/ManageInvitesScreen.kt | 71 +++++++- .../invites/ManageInvitesViewModel.kt | 5 +- .../internal/screens/GroupInviteSheet.kt | 19 +- 8 files changed, 238 insertions(+), 111 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e2d355f5eb..ee9c5d5424 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1489,7 +1489,6 @@ Manage Invites See and revoke the invite links you\'ve made Creating link… - Your link is made when you share or copy it Share Link Anyone with this link can join and see who\'s in the group. Share it only with people you want in the group @@ -1502,6 +1501,7 @@ Expired %1$s Revoked Reached its limit + No longer available Current Invite Links Invite Link @@ -1527,7 +1527,7 @@ No limit Create Link Too Many Active Links - This group has as many active invite links as it can have. Revoke one in Manage Invites to make another + Revoke a link in Manage Invites to make another Invite Links Aren\'t Available Yet You can still share the group link. People who open it can ask to join, and you approve them Share Group Link @@ -1535,6 +1535,7 @@ Something went wrong. Please try again Couldn\'t Revoke Link Couldn\'t Load Invite Links + Something went wrong. Please try again. Send to Chats Suggested diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index e5dd92fad3..5484e2d132 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -524,8 +524,8 @@ private fun FlowGroupInviteSheet() { } /** - * [ChatStep.ShareInviteLink]: the invite sheet's invite variant (frames 1b, 1f). The first Share or - * Copy tap makes a link when the host has none; sending goes through the same view model as the + * [ChatStep.ShareInviteLink]: the invite sheet's invite variant (frames 1b, 1f). The tap on Share Invite + * Link that opened it has the link made when the host had none; sending goes through the same view model as the * join variant, limited to chats an invite link may be sent into. */ @Composable @@ -540,6 +540,7 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) { val keyboard = rememberKeyboardController() val navigator = LocalCodeNavigator.current val resultBack = resultBackNavigator(exit = dismissSheet) + val flowNavigator = rememberFlowNavigator() LaunchedEffect(inviteViewModel, state.chatId) { state.chatId?.let(inviteViewModel::exclude) @@ -573,6 +574,11 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) { dismissSheet() navigator.openGroupInvite() } + // 1c's offer: revoke a link there to make room for another. + InviteLinkViewModel.Effect.OpenManageInvites -> { + dismissSheet() + flowNavigator.navigateTo(ChatStep.ManageInvites) + } InviteLinkViewModel.Effect.Close -> dismissSheet() else -> Unit } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt index 67ef995939..532cb4e030 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt @@ -31,10 +31,14 @@ internal sealed interface LinkStatus { data class Expired(val at: Instant?) : LinkStatus data object Revoked : LinkStatus data object ReachedLimit : LinkStatus + + /** A status this build doesn't know. Listed as inactive, so it says so rather than promising an expiry. */ + data object Unavailable : LinkStatus } internal fun Invite.linkStatus(): LinkStatus = when (status) { - Invite.Status.ACTIVE, Invite.Status.UNKNOWN -> expiresAt?.let(LinkStatus::Expires) ?: LinkStatus.NeverExpires + Invite.Status.ACTIVE -> expiresAt?.let(LinkStatus::Expires) ?: LinkStatus.NeverExpires + Invite.Status.UNKNOWN -> LinkStatus.Unavailable Invite.Status.EXPIRED -> LinkStatus.Expired(expiresAt) Invite.Status.REVOKED -> LinkStatus.Revoked Invite.Status.EXHAUSTED -> LinkStatus.ReachedLimit @@ -56,6 +60,7 @@ internal fun LinkStatus.label(): String = when (this) { ?: stringResource(R.string.label_inviteExpired, "") LinkStatus.Revoked -> stringResource(R.string.label_inviteRevoked) LinkStatus.ReachedLimit -> stringResource(R.string.label_inviteReachedLimit) + LinkStatus.Unavailable -> stringResource(R.string.label_inviteUnavailable) } /** The invite sheet's line under the link: "Expires Nov 3 · 0 joined". */ @@ -65,16 +70,16 @@ internal fun inviteMetaLine(expiresAt: Instant?, useCount: Long, maxUses: Long): return "$ends · ${JoinedCount(useCount, maxUses).label()}" } -/** Manage Invites' second line: joined first, then when it ends (or why it can't be shared). */ +/** + * Manage Invites' second line. An active link says how many joined, then when it ends (or why it + * can't be shared). An inactive one shows only its status and dates (spec surface 2). + */ @Composable internal fun HostInvite.detailLine(): String { + if (!isActive) return invite.linkStatus().label() val joined = JoinedCount(invite.useCount, invite.maxUses).label() - val second = when { - !isActive -> invite.linkStatus().label() - !canShare -> stringResource(R.string.label_inviteCantShare) - else -> invite.linkStatus().label() - } - return if (isActive) "$joined · $second" else "$second · $joined" + val second = if (canShare) invite.linkStatus().label() else stringResource(R.string.label_inviteCantShare) + return "$joined · $second" } /** The link as the pill shows it: scheme dropped and the middle of the secret elided (node 12933:113). */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt index c430ee9505..0232384f00 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt @@ -3,14 +3,16 @@ package com.flipcash.app.messenger.internal.invites import android.content.ClipboardManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.extensions.setSensitiveText import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess import com.flipcash.shared.chat.InviteCreation import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -24,13 +26,13 @@ import kotlin.time.Instant /** The invite link the share sheet is showing (frames 1b and 1f). */ internal sealed interface InviteLinkState { - /** Fetching the host's invites, to see whether a current link exists. */ + /** Fetching the invite the sheet was opened on. */ data object Loading : InviteLinkState - /** No current link yet. The first Share or Copy tap makes one (spec Decision 9). */ + /** Nothing to show: the sheet is about to close. */ data object None : InviteLinkState - /** Frame 1b: the first tap is making the link. */ + /** Frame 1b: the tap on Share Invite Link is making the link. */ data object Creating : InviteLinkState /** @@ -49,7 +51,9 @@ internal sealed interface InviteLinkState { /** * Backs the invite variant of the share-to-chats sheet: the host's current link, or one invite - * picked in Manage Invites, and the first-tap creation of a current link when there is none. + * picked in Manage Invites. The sheet opens from the tap on Share Invite Link, so opening it + * with no current link makes one (spec Decision 9, Open 9); opening it for a specific invite + * never creates. * * What happens after a tap is an [Effect] for the sheet to carry out, because sharing needs the * share controller and the group's picture, which only the composition has. @@ -80,15 +84,14 @@ internal class InviteLinkViewModel @Inject constructor( /** Frame 1e's offer: switch to the plain join link. */ data object OpenJoinLink : Effect - /** The invite the sheet was opened on cannot be shown; go back. */ + /** Frame 1c's offer: the host revokes a link in Manage Invites. */ + data object OpenManageInvites : Effect + + /** There is nothing to show, or the error that said so is done with; go back. */ data object Close : Effect } - data class State( - val link: InviteLinkState = InviteLinkState.Loading, - /** `CreateInvite` answered DENIED: Share and Copy show 1e again instead of calling it. */ - val unavailable: Boolean = false, - ) + data class State(val link: InviteLinkState = InviteLinkState.Loading) private val _state = MutableStateFlow(State()) val state: StateFlow = _state.asStateFlow() @@ -98,82 +101,109 @@ internal class InviteLinkViewModel @Inject constructor( private var chatId: ChatId? = null private var specific: String? = null - private var loaded = false + private var job: Job? = null - /** Starts on [chatId]'s current link, or on the invite [inviteIdHex] when Manage Invites picked one. */ + /** + * Starts on [chatId]'s current link, or on the invite [inviteIdHex] when Manage Invites picked + * one. Opening on what is already showing does nothing; a different invite reloads. + */ fun open(chatId: ChatId, inviteIdHex: String?) { if (this.chatId == chatId && specific == inviteIdHex) return this.chatId = chatId specific = inviteIdHex - loaded = false - _state.update { State() } - viewModelScope.launch { load(chatId) } + job?.cancel() + val known = if (inviteIdHex == null) hostInvites.current(chatId)?.toState() else null + _state.update { + State( + link = known + // No current link: the tap on the row made this sheet to make one. + ?: if (inviteIdHex == null) InviteLinkState.Creating else InviteLinkState.Loading, + ) + } + job = viewModelScope.launch { load(chatId, inviteIdHex) } + } + + private suspend fun load(chatId: ChatId, inviteIdHex: String?) { + val loaded = hostInvites.load(chatId) + loaded.exceptionOrNull()?.let { error -> + if (error.isLostInviteAccess()) { + // The viewer is no longer allowed to invite: back to the profile, as Manage Invites does. + hostInvites.revokeAccess(chatId) + _effects.send(Effect.Close) + return + } + } + if (inviteIdHex != null) { + val picked = hostInvites.state.value[chatId].orEmpty().firstOrNull { it.invite.id.toHex() == inviteIdHex } + val link = picked?.toState() + if (link is InviteLinkState.Ready) { + _state.update { it.copy(link = link) } + } else { + _state.update { it.copy(link = InviteLinkState.None) } + _effects.send(Effect.Close) + } + return + } + if (loaded.isFailure) { + // A load that failed could be hiding a link that exists; make none, or this would add a second. + _state.update { it.copy(link = InviteLinkState.None) } + showInviteLinksNotLoaded(resources) { _effects.trySend(Effect.Close) } + return + } + val current = hostInvites.current(chatId)?.toState() + if (current != null) { + _state.update { it.copy(link = current) } + return + } + _state.update { it.copy(link = InviteLinkState.Creating) } + create(chatId) } - private suspend fun load(chatId: ChatId): Boolean { - loaded = hostInvites.load(chatId).isSuccess - val link = specific - ?.let { id -> hostInvites.state.value[chatId].orEmpty().firstOrNull { it.invite.id.toHex() == id } } - ?: hostInvites.current(chatId).takeIf { specific == null } - _state.update { it.copy(link = link?.toState() ?: InviteLinkState.None) } - if (specific != null && link?.canShare != true) { - _effects.send(Effect.Close) + private suspend fun create(chatId: ChatId) { + when (val result = hostInvites.currentOrCreate(chatId)) { + is InviteCreation.Created -> { + val link = result.invite.toState() + _state.update { it.copy(link = link) } + if (link !is InviteLinkState.Ready) failed() + } + InviteCreation.TooMany -> { + _state.update { it.copy(link = InviteLinkState.None) } + showTooManyInviteLinks( + resources, + onManageInvites = { + _effects.trySend(Effect.OpenManageInvites) + }, + onCancel = { _effects.trySend(Effect.Close) }, + ) + } + InviteCreation.NotAvailable -> { + _state.update { it.copy(link = InviteLinkState.None) } + showInviteLinksNotAvailable( + resources, + onShareJoinLink = { _effects.trySend(Effect.OpenJoinLink) }, + onCancel = { _effects.trySend(Effect.Close) }, + ) + } + is InviteCreation.Failed -> failed() } - return loaded + } + + private fun failed() { + _state.update { it.copy(link = InviteLinkState.None) } + showInviteLinkNotCreated(resources) { _effects.trySend(Effect.Close) } } fun onShare() = act(Action.Share) fun onCopy() = act(Action.Copy) - /** The sheet's Send, which makes the link first when a chat was picked before there was one. */ fun onSend() = act(Action.Send) + // The tiles are disabled until the link is ready, so a tap that gets here without one is a + // stale gesture; it has nothing to act on. private fun act(action: Action) { - val chatId = chatId ?: return - val current = _state.value - when { - current.unavailable -> showInviteLinksNotAvailable(resources) { _effects.trySend(Effect.OpenJoinLink) } - current.link is InviteLinkState.Ready -> perform(action, current.link.url) - current.link is InviteLinkState.None && specific == null -> create(chatId, action) - } - } - - private fun create(chatId: ChatId, action: Action) { - _state.update { it.copy(link = InviteLinkState.Creating) } - viewModelScope.launch { - // A load that failed on open could be hiding a link that exists; make none until one - // has gone through, or the tap would add a second. - if (!loaded && !load(chatId)) { - _state.update { it.copy(link = InviteLinkState.None) } - showInviteLinkNotCreated(resources) - return@launch - } - val ready = (_state.value.link as? InviteLinkState.Ready) - if (ready != null) { - perform(action, ready.url) - return@launch - } - when (val result = hostInvites.currentOrCreate(chatId)) { - is InviteCreation.Created -> { - val link = result.invite.toState() - _state.update { it.copy(link = link) } - (link as? InviteLinkState.Ready)?.let { perform(action, it.url) } - } - InviteCreation.TooMany -> { - _state.update { it.copy(link = InviteLinkState.None) } - showTooManyInviteLinks(resources) - } - InviteCreation.NotAvailable -> { - _state.update { it.copy(link = InviteLinkState.None, unavailable = true) } - showInviteLinksNotAvailable(resources) { _effects.trySend(Effect.OpenJoinLink) } - } - is InviteCreation.Failed -> { - _state.update { it.copy(link = InviteLinkState.None) } - showInviteLinkNotCreated(resources) - } - } - } + val link = _state.value.link as? InviteLinkState.Ready ?: return + perform(action, link.url) } private fun perform(action: Action, url: String) { @@ -181,7 +211,7 @@ internal class InviteLinkViewModel @Inject constructor( Action.Share -> _effects.trySend(Effect.Share(url)) Action.Send -> _effects.trySend(Effect.Send(url)) Action.Copy -> { - clipboardManager.setText( + clipboardManager.setSensitiveText( text = url, label = resources.getString(R.string.title_clipboardLabelGroupInviteLink), ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt index a107cc4863..d50a5262ba 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt @@ -50,22 +50,51 @@ internal fun showInviteHostSheet( ) } -/** Frame 1c: the group holds the most active links the server allows. */ -internal fun showTooManyInviteLinks(resources: ResourceHelper) = BottomBarManager.showAlert( +/** + * Frame 1c: the group holds the most active links the server allows. With [onManageInvites] the + * buttons are Manage Invites and Cancel (spec surface 1, step 4); without it, OK only, for use + * inside Manage Invites itself. [onCancel] runs when the message is dismissed without a button. + */ +internal fun showTooManyInviteLinks( + resources: ResourceHelper, + onManageInvites: (() -> Unit)? = null, + onCancel: () -> Unit = {}, +) = BottomBarManager.showAlert( title = resources.getString(R.string.title_tooManyInviteLinks), message = resources.getString(R.string.description_tooManyInviteLinks), + actions = if (onManageInvites != null) { + listOf(BottomBarAction(text = resources.getString(R.string.action_manageInvites), onClick = onManageInvites)) + } else { + listOf(BottomBarAction.Ok) + }, + showCancel = onManageInvites != null, + onDismiss = { fromAction -> if (!fromAction) onCancel() }, ) -/** The generic "Couldn't Create Link". */ -internal fun showInviteLinkNotCreated(resources: ResourceHelper) = BottomBarManager.showError( - title = resources.getString(R.string.title_inviteLinkNotCreated), - message = resources.getString(R.string.description_inviteLinkNotCreated), -) +/** The generic "Couldn't Create Link". [onDismiss] runs once it is closed, however. */ +internal fun showInviteLinkNotCreated(resources: ResourceHelper, onDismiss: () -> Unit = {}) = + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotCreated), + message = resources.getString(R.string.description_inviteLinkNotCreated), + onDismiss = { onDismiss() }, + ) + +/** "Couldn't Load Invite Links". [onDismiss] runs once it is closed, however. */ +internal fun showInviteLinksNotLoaded(resources: ResourceHelper, onDismiss: () -> Unit = {}) = + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinksNotLoaded), + message = resources.getString(R.string.description_inviteLinkNotCreated), + onDismiss = { onDismiss() }, + ) -/** Frame 1e: invite links are not available to this host yet; [onShareJoinLink], when given, offers the plain link. */ +/** + * Frame 1e: invite links are not available to this host yet; [onShareJoinLink], when given, offers + * the plain link. [onCancel] runs when the message is dismissed without taking the offer. + */ internal fun showInviteLinksNotAvailable( resources: ResourceHelper, onShareJoinLink: (() -> Unit)? = null, + onCancel: () -> Unit = {}, ) = BottomBarManager.showMessage( title = resources.getString(R.string.title_inviteLinksNotAvailable), message = resources.getString(R.string.description_inviteLinksNotAvailable), @@ -80,6 +109,7 @@ internal fun showInviteLinksNotAvailable( listOf(BottomBarAction.Ok) }, showCancel = onShareJoinLink != null, + onDismiss = { if (it.index == -1) onCancel() }, ) /** Frame 2c: revoking cannot be undone. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt index 4c5b86170d..fd10fb9379 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape @@ -38,6 +40,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -61,6 +64,7 @@ import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.ChoiceRow import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.launch @@ -95,6 +99,7 @@ internal fun ManageInvitesScreen(chatId: ChatId?) { onBack = { flowNavigator.back() }, onRow = { rowMenu = it }, onNewLink = { newLink = true }, + onRetry = viewModel::refresh, ) rowMenu?.let { invite -> @@ -127,6 +132,7 @@ internal fun ManageInvitesContent( onBack: () -> Unit, onRow: (HostInvite) -> Unit, onNewLink: () -> Unit, + onRetry: () -> Unit = {}, ) { CodeScaffold( topBar = { @@ -143,7 +149,16 @@ internal fun ManageInvitesContent( .padding(innerPadding) .testTag("manage_invites_screen"), ) { - if (state.isEmpty) { + if (state.loading && state.active.isEmpty() && state.inactive.isEmpty()) { + // First load: not an empty list, which would read as "no links". + CodeCircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .testTag("manage_invites_loading"), + ) + } else if (state.failed) { + LoadFailed(modifier = Modifier.align(Alignment.Center), onRetry = onRetry) + } else if (state.isEmpty) { EmptyInvites(modifier = Modifier.align(Alignment.Center), onNewLink = onNewLink) } else { LazyColumn( @@ -278,6 +293,42 @@ private fun CurrentChip() { ) } +/** The first load failed: say so, and offer to try again rather than showing a blank list. */ +@Composable +private fun LoadFailed(onRetry: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(horizontal = CodeTheme.dimens.inset) + .testTag("manage_invites_failed"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_inviteLinksNotLoaded), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.widthIn(max = 280.dp), + text = stringResource(R.string.description_inviteLinksNotLoaded), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + CodeButton( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_retry), + buttonState = ButtonState.Filled10, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp), + overrideContentPadding = true, + onClick = onRetry, + ) + } +} + /** Frame 2e. */ @Composable private fun EmptyInvites(onNewLink: () -> Unit, modifier: Modifier = Modifier) { @@ -462,16 +513,22 @@ private fun OptionChips( ) { for (option in options) { val isSelected = option == selected - Text( + // The chip is a radio button in a group, and at least 48dp tall to touch. + Box( modifier = Modifier + .heightIn(min = 48.dp) .clip(CircleShape) .background(if (isSelected) CodeTheme.colors.textMain else White05) - .clickable { onSelect(option) } + .selectable(selected = isSelected, role = Role.RadioButton, onClick = { onSelect(option) }) .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x2), - text = label(option), - style = CodeTheme.typography.textSmall, - color = if (isSelected) CodeTheme.colors.background else CodeTheme.colors.textMain, - ) + contentAlignment = Alignment.Center, + ) { + Text( + text = label(option), + style = CodeTheme.typography.textSmall, + color = if (isSelected) CodeTheme.colors.background else CodeTheme.colors.textMain, + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt index b8021b8162..a9c48d0768 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt @@ -76,10 +76,7 @@ internal class ManageInvitesViewModel @Inject constructor( lostAccess(chatId) } else { _state.update { it.copy(loading = false, failed = true) } - BottomBarManager.showError( - title = resources.getString(R.string.title_inviteLinksNotLoaded), - message = resources.getString(R.string.description_inviteLinkNotCreated), - ) + showInviteLinksNotLoaded(resources) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 1fc5047821..230155ad50 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -247,7 +248,8 @@ internal fun GroupInviteSheet( // module can see. Same size and access as the avatar the conversation bar draws. val inviteLink = subject as? ShareToChatsSubject.GroupInviteLink val group = (subject as? ShareToChatsSubject.Group)?.group ?: inviteLink?.group - val creating = inviteLink?.link is InviteLinkState.Creating + // Share, Copy and Send wait for the link: the tiles are disabled rather than dropping the tap. + val creating = inviteLink != null && inviteLink.link !is InviteLinkState.Ready val picture = group?.picture val pictureUrl = rememberMediaUrl( media = picture, @@ -269,6 +271,8 @@ internal fun GroupInviteSheet( if (inviteEffects != null) { val groupTitle = group?.groupTitle val cacheKey = picture?.cacheKeyForSize(SharePreviewImage.TARGET_PX) + // The collector outlives recompositions, so it reads the latest callback rather than the first. + val currentOnInviteEffect by rememberUpdatedState(onInviteEffect) LaunchedEffect(inviteEffects) { inviteEffects.collect { effect -> when (effect) { @@ -281,7 +285,7 @@ internal fun GroupInviteSheet( ) ) InviteLinkViewModel.Effect.Copied -> copied = true - else -> onInviteEffect(effect) + else -> currentOnInviteEffect(effect) } } } @@ -569,7 +573,7 @@ private fun InviteLinkTile( /** * Node 12933:113 — the invite link in a pill, with its expiry and joined count under it; while the - * first tap makes the link, a spinner and "Creating link…" (node 12933:210). The link is cut in the + * tap on Share Invite Link makes the link, a spinner and "Creating link…" (node 12933:210). The link is cut in the * middle so the secret is never shown whole. */ @Composable @@ -600,12 +604,9 @@ internal fun InviteLinkPill(link: InviteLinkState, modifier: Modifier = Modifier maxLines = 1, overflow = TextOverflow.Clip, ) - // Nothing to show before the first tap makes a link, or while it is fetched. - InviteLinkState.None, InviteLinkState.Loading -> Text( - text = stringResource(R.string.label_inviteLinkPlaceholder), - style = CodeTheme.typography.textMedium, - color = White50, - ) + // A picked invite being fetched, or nothing to show as the sheet closes. + InviteLinkState.None, InviteLinkState.Loading -> + CodeCircularProgressIndicator(modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6 / 2)) } } if (link is InviteLinkState.Ready) { From 43f6bd2e32400af39c6df94acf8ca748b106da13 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:18:14 -0400 Subject: [PATCH 15/20] test(messenger): cover invite creation on open, errors, copy, and Manage Invites InviteLinkViewModel: creating to ready, each error mapping, sensitive clipboard copy, send, re-open, join-link and Manage Invites offers, denied at open. ManageInvitesViewModel: each create result, double-tap guard, revoke failure, retry. Also restrictToEncrypted, the profile menu truth table, no card tile on an invite link, and teardown clearing HostInvites. --- .../internal/GroupInviteViewModelTest.kt | 37 ++++ .../invites/InviteLinkViewModelTest.kt | 166 +++++++++++++----- .../invites/ManageInvitesViewModelTest.kt | 100 +++++++++++ .../internal/screens/GroupCardTileTest.kt | 5 + .../chat/ChatCoordinatorTeardownTest.kt | 12 ++ 5 files changed, 276 insertions(+), 44 deletions(-) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt index f41b04e90d..5912a8d5ce 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.getcode.chatcipher.ChatEncryptionPolicy import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager @@ -208,6 +210,41 @@ class GroupInviteViewModelTest { assertEquals(listOf(privateGroup, publicGroup), model.state.value.invitable?.map { it.chatId }) } + @Test + fun `restricting to encrypted keeps an E2EE DM, drops the flipcash DM and prunes the selection`() = runTest(scheduler) { + val e2eeDm = ChatId(ByteArray(32) { 10 }) + val flipcashDm = ChatId(ByteArray(32) { 11 }) + fun dm(id: ChatId, peer: ByteArray) = summary(id, ChatType.CONTACT_DM).let { + it.copy( + metadata = it.metadata.copy( + useE2ee = true, + members = listOf( + ChatMember( + userId = peer.toList(), + userProfile = mockk(relaxed = true), + pointers = emptyList(), + ), + ), + ), + ) + } + every { chatCoordinator.feed(*anyVararg()) } returns flowOf( + listOf( + dm(e2eeDm, ByteArray(16) { 3 }), + dm(flipcashDm, ChatEncryptionPolicy.FLIPCASH_USER_ID), + ), + ) + val model = viewModel() + advanceUntilIdle() + model.toggle(e2eeDm) + model.toggle(flipcashDm) + + model.restrictToEncrypted(true) + + assertEquals(listOf(e2eeDm), model.state.value.invitable?.map { it.chatId }) + assertEquals(listOf(e2eeDm), model.state.value.selection) + } + @Test fun `a person's DM is left out of the chats their profile is sent to`() = runTest(scheduler) { val dm = ChatId(ByteArray(32) { 7 }) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt index e7fded3f4c..5ce4ca479d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt @@ -1,5 +1,10 @@ package com.flipcash.app.messenger.internal.invites +import android.content.ClipboardManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.core.extensions.SensitiveClipKey +import com.flipcash.services.models.GetChatInvitesError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Invite import com.flipcash.services.models.chat.InviteId @@ -7,14 +12,17 @@ import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.InviteCreation import com.getcode.manager.BottomBarManager +import com.getcode.manager.SelectedBottomBarAction import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.verify import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -26,20 +34,26 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import kotlin.time.Instant /** - * The invite variant of the share sheet (frames 1b, 1f, 1c, 1e): nothing is made on open, the - * first Share or Copy tap makes the link, and each way CreateInvite can end maps to its own alert. + * The invite variant of the share sheet (frames 1b, 1f, 1c, 1e). The sheet opens from the tap on + * Share Invite Link, so opening with no current link makes one; each way CreateInvite can end maps + * to its own alert; a specific invite from Manage Invites never creates. */ @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) class InviteLinkViewModelTest { private val chatId = ChatId(ByteArray(32) { 5 }) - private val hostInvites = mockk() + private val hostInvites = mockk(relaxed = true) private val resources = mockk(relaxed = true) { every { getString(any()) } returns "text" } + private val clipboard = + ApplicationProvider.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager private val listed = MutableStateFlow>>(emptyMap()) @Before @@ -57,11 +71,13 @@ class InviteLinkViewModelTest { Dispatchers.resetMain() } - private fun viewModel() = InviteLinkViewModel(hostInvites, resources, mockk(relaxed = true)) + private fun viewModel() = InviteLinkViewModel(hostInvites, resources, clipboard) + private var seed = 1 private fun hostInvite(url: String?, useCount: Long = 0, maxUses: Long = 0): HostInvite { + val n = seed++ val invite = mockk(relaxed = true) { - every { id } returns InviteId(ByteArray(32) { 1 }) + every { id } returns InviteId(ByteArray(32) { n.toByte() }) every { expiresAt } returns Instant.fromEpochSeconds(1_800_000_000) every { this@mockk.useCount } returns useCount every { this@mockk.maxUses } returns maxUses @@ -70,16 +86,38 @@ class InviteLinkViewModelTest { } @Test - fun `opening makes nothing`() = runTest { + fun `opening with no current link creates at once and goes through creating to ready`() = runTest { + val gate = CompletableDeferred() + coEvery { hostInvites.currentOrCreate(chatId) } coAnswers { gate.await() } val model = viewModel() + model.open(chatId, null) + assertEquals(InviteLinkState.Creating, model.state.value.link) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } - assertEquals(InviteLinkState.None, model.state.value.link) - coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + gate.complete(InviteCreation.Created(hostInvite("https://x/chat/invite#s=new", maxUses = 5))) + val ready = model.state.value.link as InviteLinkState.Ready + assertEquals("https://x/chat/invite#s=new", ready.url) + assertEquals(5L, ready.maxUses) } @Test - fun `an existing current link is shown and shared without creating`() = runTest { + fun `tiles do nothing while the link is being made`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } coAnswers { kotlinx.coroutines.awaitCancellation() } + val model = viewModel() + model.open(chatId, null) + + model.onShare() + model.onCopy() + model.onSend() + + assertEquals(InviteLinkState.Creating, model.state.value.link) + assertEquals(null, clipboard.primaryClip) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + } + + @Test + fun `an existing current link opens ready without creating`() = runTest { every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a", useCount = 2, maxUses = 10) val model = viewModel() model.open(chatId, null) @@ -88,82 +126,105 @@ class InviteLinkViewModelTest { assertEquals(2L, ready.useCount) model.onShare() - val effect = model.effects.first() as InviteLinkViewModel.Effect.Share - assertEquals("https://x/chat/invite#s=a", effect.url) + assertEquals("https://x/chat/invite#s=a", (model.effects.first() as InviteLinkViewModel.Effect.Share).url) coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } } @Test - fun `the first tap creates the link and then shares it`() = runTest { - coEvery { hostInvites.currentOrCreate(chatId) } returns - InviteCreation.Created(hostInvite("https://x/chat/invite#s=new")) + fun `copy puts the link on the clipboard marked sensitive and says copied`() = runTest { + every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a") val model = viewModel() model.open(chatId, null) - model.onShare() + model.onCopy() - assertTrue(model.state.value.link is InviteLinkState.Ready) - assertEquals("https://x/chat/invite#s=new", (model.effects.first() as InviteLinkViewModel.Effect.Share).url) - coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + val clip = clipboard.primaryClip!! + assertEquals("https://x/chat/invite#s=a", clip.getItemAt(0).text.toString()) + assertTrue(clip.description.extras!!.getBoolean(SensitiveClipKey)) + assertEquals(InviteLinkViewModel.Effect.Copied, model.effects.first()) } @Test - fun `a second tap while creating does not call create again`() = runTest { - coEvery { hostInvites.currentOrCreate(chatId) } coAnswers { - kotlinx.coroutines.awaitCancellation() - } + fun `send hands the link to the picked chats`() = runTest { + every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a") val model = viewModel() model.open(chatId, null) - model.onShare() - model.onCopy() + model.onSend() - assertEquals(InviteLinkState.Creating, model.state.value.link) - coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + assertEquals("https://x/chat/invite#s=a", (model.effects.first() as InviteLinkViewModel.Effect.Send).url) } @Test - fun `denied shows the unavailable alert and a later tap does not call create again`() = runTest { + fun `denied shows 1e with the join link on offer and closes behind it`() = runTest { coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable val model = viewModel() model.open(chatId, null) - model.onShare() - assertTrue(model.state.value.unavailable) - assertEquals(1, BottomBarManager.messages.value.size) - assertEquals(1, BottomBarManager.messages.value.single().actions.size) + assertEquals(InviteLinkState.None, model.state.value.link) + val message = BottomBarManager.messages.value.single() + assertTrue(message.showCancel) + message.actions.first().onClick() + assertEquals(InviteLinkViewModel.Effect.OpenJoinLink, model.effects.first()) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + } - BottomBarManager.clear() - model.onCopy() + @Test + fun `dismissing 1e closes the sheet`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable + val model = viewModel() + model.open(chatId, null) - coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } - assertEquals(1, BottomBarManager.messages.value.size) + BottomBarManager.messages.value.single().onClose(SelectedBottomBarAction(-1)) + + assertEquals(InviteLinkViewModel.Effect.Close, model.effects.first()) } @Test - fun `too many invites shows an alert and leaves the sheet ready to try again`() = runTest { + fun `too many offers Manage Invites and Cancel`() = runTest { coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.TooMany val model = viewModel() model.open(chatId, null) - model.onShare() - + val message = BottomBarManager.messages.value.single() + assertTrue(message.showCancel) + message.actions.first().onClick() + assertEquals(InviteLinkViewModel.Effect.OpenManageInvites, model.effects.first()) assertEquals(InviteLinkState.None, model.state.value.link) - assertFalse(model.state.value.unavailable) - assertEquals(1, BottomBarManager.messages.value.size) } @Test - fun `any other failure shows the generic error and can be tried again`() = runTest { + fun `any other failure shows the generic error and closes behind it`() = runTest { coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.Failed(IllegalStateException()) val model = viewModel() model.open(chatId, null) - model.onShare() + assertEquals(InviteLinkState.None, model.state.value.link) + BottomBarManager.messages.value.single().onClose(SelectedBottomBarAction(-1)) + assertEquals(InviteLinkViewModel.Effect.Close, model.effects.first()) + } + + @Test + fun `a denied list at open takes the access away and closes`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(GetChatInvitesError.Denied()) + val model = viewModel() + + model.open(chatId, null) + + verify { hostInvites.revokeAccess(chatId) } + assertEquals(InviteLinkViewModel.Effect.Close, model.effects.first()) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `a list that fails to load makes nothing`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(IllegalStateException()) + val model = viewModel() + + model.open(chatId, null) assertEquals(InviteLinkState.None, model.state.value.link) - assertFalse(model.state.value.unavailable) - assertEquals(1, BottomBarManager.messages.value.size) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } } @Test @@ -190,6 +251,23 @@ class InviteLinkViewModelTest { coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } } + @Test + fun `reopening on a different invite reloads and on the same one does nothing`() = runTest { + val first = hostInvite("https://x/chat/invite#s=one") + val second = hostInvite("https://x/chat/invite#s=two") + listed.value = mapOf(chatId to listOf(first, second)) + val model = viewModel() + + model.open(chatId, first.invite.id.toHex()) + model.open(chatId, first.invite.id.toHex()) + coVerify(exactly = 1) { hostInvites.load(chatId) } + assertEquals("https://x/chat/invite#s=one", (model.state.value.link as InviteLinkState.Ready).url) + + model.open(chatId, second.invite.id.toHex()) + coVerify(exactly = 2) { hostInvites.load(chatId) } + assertEquals("https://x/chat/invite#s=two", (model.state.value.link as InviteLinkState.Ready).url) + } + @Test fun `state and effects never print the link`() { val ready = InviteLinkState.Ready("https://x/chat/invite#s=secret", null, 0, 0) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt index 34e40d22a6..ac41bf3f94 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt @@ -12,6 +12,7 @@ import com.flipcash.shared.chat.InviteSettings import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -168,4 +169,103 @@ class ManageInvitesViewModelTest { assertTrue(model.effects.first() is ManageInvitesViewModel.Effect.Created) assertFalse(model.state.value.creating) } + + @Test + fun `a created link becomes the current one`() = runTest { + val created = hostInvite() + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, InviteSettings.Default) } coAnswers { + listed.value = mapOf(chatId to listOf(created)) + every { hostInvites.current(chatId) } returns created + InviteCreation.Created(created) + } + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + + assertEquals(created, model.state.value.current) + assertEquals(listOf(created), model.state.value.active) + } + + @Test + fun `too many from inside Manage Invites is OK only`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.TooMany + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + + val message = BottomBarManager.messages.value.single() + assertFalse(message.showCancel) + assertEquals(1, message.actions.size) + assertFalse(model.state.value.creating) + } + + @Test + fun `denied and failed creates each show one message and re-enable the button`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + val model = viewModel() + model.open(chatId) + + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.NotAvailable + model.create(InviteSettings.Default) + assertEquals(1, BottomBarManager.messages.value.size) + assertFalse(model.state.value.creating) + + BottomBarManager.clear() + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.Failed(IllegalStateException()) + model.create(InviteSettings.Default) + assertEquals(1, BottomBarManager.messages.value.size) + assertFalse(model.state.value.creating) + } + + @Test + fun `a second create while one is in flight is ignored`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, any()) } coAnswers { kotlinx.coroutines.awaitCancellation() } + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + model.create(InviteSettings.Default) + + assertTrue(model.state.value.creating) + coVerify(exactly = 1) { hostInvites.create(chatId, any()) } + } + + @Test + fun `a failed revoke shows an error and leaves the list as it was`() = runTest { + val active = hostInvite() + listed.value = mapOf(chatId to listOf(active)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + coEvery { hostInvites.revoke(chatId, active.invite.id) } returns Result.failure(IllegalStateException()) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + val confirm = BottomBarManager.messages.value.single() + confirm.actions.single().onClick() + + assertEquals(listOf(active), model.state.value.active) + // The confirmation is still up (the UI removes it on a tap); the error is the second. + assertEquals(2, BottomBarManager.messages.value.size) + assertTrue(BottomBarManager.messages.value.last().isError) + verify(exactly = 0) { hostInvites.revokeAccess(any()) } + } + + @Test + fun `refresh after a failed first load loads again`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(IllegalStateException()) + val model = viewModel() + model.open(chatId) + assertTrue(model.state.value.failed) + + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + model.refresh() + + assertFalse(model.state.value.failed) + assertTrue(model.state.value.isEmpty) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt index 3f403af148..c7f4264093 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt @@ -27,4 +27,9 @@ class GroupCardTileTest { fun `tile id is group-card-tile`() { assertEquals("group-card-tile", GroupCardTileTag) } + + @Test + fun `an invite link sheet has no card tile even when the host supplies a card`() { + assertFalse(showsCardTile(ShareToChatsSubject.GroupInviteLink(group = null, link = com.flipcash.app.messenger.internal.invites.InviteLinkState.Creating)) {}) + } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 179da505f6..0836686397 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -25,6 +25,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -54,6 +55,7 @@ class ChatCoordinatorTeardownTest { private lateinit var draftStore: ChatDraftStore private lateinit var archiveStore: ChatArchiveStore private lateinit var groupFeedDelegate: GroupFeedDelegate + private lateinit var hostInvites: HostInvites private lateinit var coordinator: RealChatCoordinator @Before @@ -75,6 +77,7 @@ class ChatCoordinatorTeardownTest { draftStore = mockk(relaxed = true) archiveStore = mockk(relaxed = true) groupFeedDelegate = mockk(relaxed = true) + hostInvites = mockk(relaxed = true) val messagingController = mockk(relaxed = true) val stateHolder = ChatStateHolder() @@ -126,6 +129,7 @@ class ChatCoordinatorTeardownTest { groupFeedDelegate = groupFeedDelegate, reactionsDelegate = mockk(relaxed = true), archiveStore = archiveStore, + hostInvites = hostInvites, ) } @@ -150,6 +154,14 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 0) { draftStore.clearAll() } } + /** The invites belong to the account, and the links among them are as good as the chat key. */ + @Test + fun `teardown drops the held invites`() = runTest { + coordinator.teardown() + + verify(exactly = 1) { hostInvites.clear() } + } + @Test fun `clearCache erases metadata, messages, and members`() = runTest { coordinator.clearCache() From 63e334c36f541c534c58211ee5c4aa9cc04e2e68 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:26:09 -0400 Subject: [PATCH 16/20] fix(messenger): present the invite link row sheet on the app's sheet scene The Invite Link row sheet on Manage Invites was a raw Material3 ModalBottomSheet, which added a drag handle and top spacing the other choice sheets (Mute Chat, Paid Reactions) do not have. Make it a ChatStep.InviteLinkActions marked WrapContentSheet so it renders on the same scene. The step carries only the invite id; the sheet looks the invite up in the host's loaded invites. It returns Share or Revoke to Manage Invites, which opens the share sheet or runs the existing revoke confirm. --- .../com/flipcash/app/core/chat/ChatStep.kt | 20 ++++++++ .../flipcash/app/messenger/ChatFlowScreen.kt | 32 +++++++++++++ .../invites/InviteLinkActionsViewModel.kt | 21 ++++++++ .../internal/invites/ManageInvitesScreen.kt | 48 ++++++------------- 4 files changed, 88 insertions(+), 33 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkActionsViewModel.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 6d2a0a20f8..2f31a24302 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -28,6 +28,11 @@ data object ChatSendResult : Parcelable @Serializable data class GroupInviteResult(val chatId: ChatId) : Parcelable +/** What the host picked on Manage Invites' row sheet (frame 2b). */ +@Parcelize +@Serializable +enum class InviteLinkAction : Parcelable { Share, Revoke } + /** A custom paid-reaction amount, in whole US cents. */ @Parcelize @Serializable @@ -103,6 +108,21 @@ sealed interface ChatStep : FlowStep, Parcelable { Sheet, com.getcode.navigation.FullscreenSheet + /** + * Frame 2b: Share Link and Revoke Link for one invite on Manage Invites. Returns the pick and + * leaves the work to Manage Invites, which is still on screen beneath it and owns the revoke + * confirm (2c). + * + * Carries the invite's id and never its link, as [ShareInviteLink] does. + */ + @Parcelize + @Serializable + data class InviteLinkActions(val inviteIdHex: String) : + ChatStep, + NavigationRetVal, + Sheet, + WrapContentSheet + /** Frame 2a: the invite links this host has made, Active then Inactive. Pushed, like [EditGroup]. */ @Parcelize @Serializable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 5484e2d132..27e97ea85d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -38,6 +38,9 @@ import com.flipcash.app.core.util.Linkify import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.core.chat.InviteLinkAction +import com.flipcash.app.messenger.internal.invites.InviteLinkActions +import com.flipcash.app.messenger.internal.invites.InviteLinkActionsViewModel import com.flipcash.app.messenger.internal.invites.InviteLinkViewModel import com.flipcash.app.messenger.internal.invites.ManageInvitesScreen import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel @@ -176,6 +179,9 @@ private fun chatEntryProvider( annotatedEntry { step -> FlowInviteLinkSheet(step.inviteIdHex) } + annotatedEntry { step -> + FlowInviteLinkActionsSheet(step.inviteIdHex) + } annotatedEntry { ManageInvitesScreen(flowSharedViewModel().stateFlow.collectAsStateWithLifecycle().value.chatId) } @@ -586,6 +592,32 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) { ) } +/** + * [ChatStep.InviteLinkActions]: frame 2b on the app's sheet scene, like the other choice sheets. + * It returns the pick and closes; Manage Invites, still beneath it, shares or confirms the revoke. + * An invite that is gone by the time the sheet draws closes it, as a missing share link does. + */ +@Composable +private fun FlowInviteLinkActionsSheet(inviteIdHex: String) { + val chatId = flowSharedViewModel().stateFlow.collectAsStateWithLifecycle().value.chatId + val viewModel = hiltViewModel() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) + val invite = chatId?.let { viewModel.invite(it, inviteIdHex) } + + LaunchedEffect(invite == null && chatId != null) { + if (invite == null && chatId != null) dismissSheet() + } + invite ?: return + + InviteLinkActions( + invite = invite, + onShare = { resultBack.returnValue(InviteLinkAction.Share) }, + onRevoke = { resultBack.returnValue(InviteLinkAction.Revoke) }, + onDismiss = dismissSheet, + ) +} + /** * [ChatStep.ShareMessage]: the invite sheet handing out one message's link. Same send path and * result as [FlowGroupInviteSheet]; the link is the whole subject. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkActionsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkActionsViewModel.kt new file mode 100644 index 0000000000..74cbb9104d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkActionsViewModel.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.messenger.internal.invites + +import androidx.lifecycle.ViewModel +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.HostInvites +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Frame 2b's lookup: the invite a row sheet was opened on, from the invites Manage Invites has + * already loaded. The step carries only the invite's id, so the sheet finds the rest here. + */ +@HiltViewModel +internal class InviteLinkActionsViewModel @Inject constructor( + private val hostInvites: HostInvites, +) : ViewModel() { + + fun invite(chatId: ChatId, inviteIdHex: String): HostInvite? = + hostInvites.state.value[chatId].orEmpty().firstOrNull { it.invite.id.toHex() == inviteIdHex } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt index fd10fb9379..1cbb45574a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.InviteLinkAction import com.flipcash.app.messenger.internal.screens.openInviteLinkShare import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId @@ -56,6 +57,8 @@ import com.flipcash.shared.chat.InviteSettings import com.flipcash.shared.chat.InviteUses import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.navigateForResult import com.getcode.theme.CodeTheme import com.getcode.theme.White05 import com.getcode.theme.White50 @@ -81,7 +84,6 @@ internal fun ManageInvitesScreen(chatId: ChatId?) { LaunchedEffect(chatId) { chatId?.let(viewModel::open) } - var rowMenu by remember { mutableStateOf(null) } var newLink by remember { mutableStateOf(false) } LaunchedEffect(viewModel) { @@ -97,25 +99,22 @@ internal fun ManageInvitesScreen(chatId: ChatId?) { ManageInvitesContent( state = state, onBack = { flowNavigator.back() }, - onRow = { rowMenu = it }, + onRow = { invite -> + // The row sheet is a step of its own; the pick comes back here, where the list is. + navigator.navigateForResult( + ChatStep.InviteLinkActions(invite.invite.id.toHex()) + ) { result -> + if (result !is NavResultOrCanceled.ReturnValue) return@navigateForResult + when (result.value) { + InviteLinkAction.Share -> navigator.openInviteLinkShare(invite.invite.id.toHex()) + InviteLinkAction.Revoke -> viewModel.confirmRevoke(invite) + } + } + }, onNewLink = { newLink = true }, onRetry = viewModel::refresh, ) - rowMenu?.let { invite -> - InviteLinkActionsSheet( - invite = invite, - onShare = { - rowMenu = null - navigator.openInviteLinkShare(invite.invite.id.toHex()) - }, - onRevoke = { - rowMenu = null - viewModel.confirmRevoke(invite) - }, - onDismiss = { rowMenu = null }, - ) - } if (newLink) { NewInviteLinkSheet( creating = state.creating, @@ -364,23 +363,6 @@ private fun EmptyInvites(onNewLink: () -> Unit, modifier: Modifier = Modifier) { } /** Frame 2b: Share Link and Revoke Link, in the mute sheet's style. A link that can't be shared gets Revoke only. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun InviteLinkActionsSheet( - invite: HostInvite, - onShare: () -> Unit, - onRevoke: () -> Unit, - onDismiss: () -> Unit, -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = CodeTheme.colors.background, - ) { - InviteLinkActions(invite = invite, onShare = onShare, onRevoke = onRevoke, onDismiss = onDismiss) - } -} - @Composable internal fun InviteLinkActions( invite: HostInvite, From abc935df15f2ecbd60730777bceaf51b8fcd2a3a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 17:25:28 -0400 Subject: [PATCH 17/20] refactor(ui): move the request-time preset chips into SlidingChipRow The Time to Pay sheet's preset chips slide one white highlight to the chosen chip and fade it out for a custom date. New Invite Link needs the same row, so the chips move to ui:components as SlidingChipRow, with the same colours, padding, caption style and spring. The chips now carry the radio-button role in a selectable group, where they were buttons with a selected state. Nothing on screen changes. --- .../internal/request/TimeToPaySheet.kt | 79 +---------- .../getcode/ui/components/SlidingChipRow.kt | 133 ++++++++++++++++++ .../ui/components/SlidingChipRowTest.kt | 83 +++++++++++ 3 files changed, 221 insertions(+), 74 deletions(-) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt create mode 100644 ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt index 524efda737..181c15d464 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt @@ -1,6 +1,5 @@ package com.flipcash.app.messenger.internal.request -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring @@ -10,7 +9,6 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,8 +19,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Keyboard @@ -44,7 +40,6 @@ import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,11 +49,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.unit.toSize import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path @@ -90,6 +81,7 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White05 import com.getcode.theme.White10 +import com.getcode.ui.components.SlidingChipRow import com.getcode.ui.theme.CodeButton import com.flipcash.shared.chat.ui.media.rememberReducedMotion import kotlinx.coroutines.launch @@ -143,10 +135,12 @@ internal fun TimeToPaySheet( textAlign = TextAlign.Center, ) RangeFields(sheet) - PresetChips( + SlidingChipRow( + options = Deadline.Preset.entries, selected = (sheet.choice as? TimeToPay.Choice.Preset)?.preset, + onSelect = { sheet = sheet.choosePreset(it) }, + label = { stringResource(it.label) }, reducedMotion = reducedMotion, - onChoose = { sheet = sheet.choosePreset(it) }, ) DayGrid(sheet, reducedMotion, onChoose = { sheet = sheet.chooseDay(it) }) TimeRow(time = sheet.time, onClick = { pickingTime = true }) @@ -246,69 +240,6 @@ private fun RangeField(label: String, value: String, outlined: Boolean, modifier } } -/** - * The preset chips. One white highlight slides to the chosen chip and fades out when the choice - * becomes custom. - */ -@Composable -private fun PresetChips(selected: Deadline.Preset?, reducedMotion: Boolean, onChoose: (Deadline.Preset) -> Unit) { - val bounds = remember { mutableStateMapOf() } - val highlight = remember { Animatable(Rect.Zero, Rect.VectorConverter) } - val shown = remember { Animatable(0f) } - var placed by remember { mutableStateOf(false) } - val target = selected?.let { bounds[it] } - LaunchedEffect(selected, target) { - val spec = TimeToPayMotion.spec(reducedMotion) - when { - selected == null -> shown.animateTo(0f, spec) - target == null -> Unit - !placed -> { - highlight.snapTo(target) - shown.snapTo(1f) - placed = true - } - else -> { - launch { highlight.animateTo(target, TimeToPayMotion.spec(reducedMotion)) } - shown.animateTo(1f, spec) - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .drawBehind { - bounds.values.forEach { chip -> - drawRoundRect(White10, chip.topLeft, chip.size, CornerRadius(chip.height / 2)) - } - val chip = highlight.value - drawRoundRect(White, chip.topLeft, chip.size, CornerRadius(chip.height / 2), alpha = shown.value) - }, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { - Deadline.Preset.entries.forEach { preset -> - val isSelected = preset == selected - val color by animateColorAsState( - targetValue = if (isSelected) Color.Black else CodeTheme.colors.textMain, - animationSpec = TimeToPayMotion.spec(reducedMotion), - label = "chip text", - ) - Text( - modifier = Modifier - .onPlaced { bounds[preset] = Rect(it.positionInParent(), it.size.toSize()) } - .clip(CircleShape) - .clickable(role = Role.Button) { onChoose(preset) } - .semantics { this.selected = isSelected } - .padding(horizontal = 10.dp, vertical = 7.dp), - text = stringResource(preset.label), - style = CodeTheme.typography.caption, - color = color, - maxLines = 1, - ) - } - } -} - /** * The days a request can run to. The range draws over the grid as one layer: the band as one span * per week row, today's grey circle, and one white closing-day circle that slides to a new day, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt new file mode 100644 index 0000000000..e8a0574af6 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt @@ -0,0 +1,133 @@ +package com.getcode.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem +import com.getcode.theme.White +import com.getcode.theme.White10 +import kotlinx.coroutines.launch + +/** + * A row of preset chips with one white highlight that slides to the chosen chip, and fades out + * when [selected] is null (a choice none of the chips stands for). + * + * With [reducedMotion] on, the highlight moves and fades straight to its new place. + */ +@Composable +fun SlidingChipRow( + options: List, + selected: T?, + onSelect: (T) -> Unit, + label: @Composable (T) -> String, + modifier: Modifier = Modifier, + reducedMotion: Boolean = false, +) { + val bounds = remember { mutableStateMapOf() } + val highlight = remember { Animatable(Rect.Zero, Rect.VectorConverter) } + val shown = remember { Animatable(0f) } + var placed by remember { mutableStateOf(false) } + val target = selected?.let { bounds[it] } + LaunchedEffect(selected, target) { + val spec = chipMotion(reducedMotion) + when { + selected == null -> shown.animateTo(0f, spec) + target == null -> Unit + !placed -> { + highlight.snapTo(target) + shown.snapTo(1f) + placed = true + } + else -> { + launch { highlight.animateTo(target, chipMotion(reducedMotion)) } + shown.animateTo(1f, spec) + } + } + } + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .selectableGroup() + .drawBehind { + bounds.values.forEach { chip -> + drawRoundRect(White10, chip.topLeft, chip.size, CornerRadius(chip.height / 2)) + } + val chip = highlight.value + drawRoundRect(White, chip.topLeft, chip.size, CornerRadius(chip.height / 2), alpha = shown.value) + }, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + options.forEach { option -> + val isSelected = option == selected + val color by animateColorAsState( + targetValue = if (isSelected) Color.Black else CodeTheme.colors.textMain, + animationSpec = chipMotion(reducedMotion), + label = "chip text", + ) + Text( + modifier = Modifier + .onPlaced { bounds[option] = Rect(it.positionInParent(), it.size.toSize()) } + .clip(CircleShape) + .selectable(selected = isSelected, role = Role.RadioButton) { onSelect(option) } + .padding(horizontal = 10.dp, vertical = 7.dp), + text = label(option), + style = CodeTheme.typography.caption, + color = color, + maxLines = 1, + ) + } + } +} + +private fun chipMotion(reducedMotion: Boolean): AnimationSpec = + if (reducedMotion) snap() else spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow) + +@Preview +@Composable +private fun SlidingChipRowPreview() { + DesignSystem { + var selected by remember { mutableStateOf("7 days") } + SlidingChipRow( + options = listOf("1 day", "7 days", "30 days", "Never"), + selected = selected, + onSelect = { selected = it }, + label = { it }, + ) + } +} diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt new file mode 100644 index 0000000000..e85f097807 --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt @@ -0,0 +1,83 @@ +package com.getcode.ui.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class SlidingChipRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val options = listOf("1 day", "7 days", "30 days") + + @Test + fun `the selected chip is selected and the others are not`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow(options = options, selected = "7 days", onSelect = {}, label = { it }) + } + } + composeTestRule.onNodeWithText("7 days").assertIsSelected() + composeTestRule.onNodeWithText("1 day").assertIsNotSelected() + composeTestRule.onNodeWithText("30 days").assertIsNotSelected() + } + + @Test + fun `each chip is a radio button`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow(options = options, selected = "1 day", onSelect = {}, label = { it }) + } + } + composeTestRule.onNodeWithText("30 days") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton)) + } + + @Test + fun `tapping a chip selects it`() { + val picks = mutableListOf() + composeTestRule.setContent { + DesignSystem { + var selected by remember { mutableStateOf("1 day") } + SlidingChipRow( + options = options, + selected = selected, + onSelect = { picks += it; selected = it }, + label = { it }, + ) + } + } + composeTestRule.onNodeWithText("30 days").performClick() + assertEquals(listOf("30 days"), picks) + composeTestRule.onNodeWithText("30 days").assertIsSelected() + composeTestRule.onNodeWithText("1 day").assertIsNotSelected() + } + + @Test + fun `no chip is selected for a choice none of them stands for`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow(options = options, selected = null, onSelect = {}, label = { it }) + } + } + options.forEach { composeTestRule.onNodeWithText(it).assertIsNotSelected() } + } +} From b021d19fb89995ed132e66488a971ddd7f9d101b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 20:00:55 -0400 Subject: [PATCH 18/20] feat(ui): give each SlidingChipRow chip an equal share of the row Matches iOS's SlidingChipPicker: chips weight(1f) across the full width, 6dp apart, with 6dp side padding and centred labels. The row no longer scrolls. Time To Pay's presets change with it. A label too wide for its chip shrinks by up to 20%, like iOS's minimumScaleFactor(0.8), then wraps at the space. Every chip takes the height of the tallest. TextAutoSize can't do this alone: with two lines allowed it wraps before it shrinks, so the row measures each label and picks the size. At font scale 2, "Weeks" at 80% is wider than a fifth of the row, so Time To Pay's "2 Weeks" and "4 Weeks" break inside the word; in a 320dp row "1 Week" does too. They stay whole on screen. --- .../getcode/ui/components/SlidingChipRow.kt | 118 +++++++++++++----- .../ui/components/SlidingChipRowTest.kt | 93 ++++++++++++++ 2 files changed, 177 insertions(+), 34 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt index e8a0574af6..1578a54b54 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt @@ -7,12 +7,15 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape @@ -33,7 +36,13 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize @@ -45,7 +54,10 @@ import kotlinx.coroutines.launch /** * A row of preset chips with one white highlight that slides to the chosen chip, and fades out - * when [selected] is null (a choice none of the chips stands for). + * when [selected] is null (a choice none of the chips stands for). The chips share the row's width + * equally, as iOS's SlidingChipPicker does. A label too wide for its chip shrinks by up to 20%, + * like iOS's minimumScaleFactor(0.8), and past that wraps at the space, so none is cut. Every chip + * takes the height of the tallest, so the capsules stay uniform. * * With [reducedMotion] on, the highlight moves and fades straight to its new place. */ @@ -63,6 +75,9 @@ fun SlidingChipRow( val shown = remember { Animatable(0f) } var placed by remember { mutableStateOf(false) } val target = selected?.let { bounds[it] } + val labels = options.map { label(it) } + val style = CodeTheme.typography.caption + val measurer = rememberTextMeasurer() LaunchedEffect(selected, target) { val spec = chipMotion(reducedMotion) when { @@ -79,42 +94,77 @@ fun SlidingChipRow( } } } - Row( - modifier = modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .selectableGroup() - .drawBehind { - bounds.values.forEach { chip -> - drawRoundRect(White10, chip.topLeft, chip.size, CornerRadius(chip.height / 2)) + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val gap = 6.dp + val textWidth = with(LocalDensity.current) { + ((maxWidth - gap * (options.size - 1)) / options.size - ChipPadding * 2).toPx().toInt() + } + val styles = remember(labels, style, textWidth, measurer) { + labels.map { fitStyle(it, style, textWidth, measurer) } + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .selectableGroup() + .drawBehind { + bounds.values.forEach { chip -> + drawRoundRect(White10, chip.topLeft, chip.size, CornerRadius(chip.height / 2)) + } + val chip = highlight.value + drawRoundRect(White, chip.topLeft, chip.size, CornerRadius(chip.height / 2), alpha = shown.value) + }, + horizontalArrangement = Arrangement.spacedBy(gap), + ) { + options.forEachIndexed { index, option -> + val isSelected = option == selected + val color by animateColorAsState( + targetValue = if (isSelected) Color.Black else CodeTheme.colors.textMain, + animationSpec = chipMotion(reducedMotion), + label = "chip text", + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .onPlaced { bounds[option] = Rect(it.positionInParent(), it.size.toSize()) } + .clip(CircleShape) + .selectable(selected = isSelected, role = Role.RadioButton) { onSelect(option) } + .padding(horizontal = ChipPadding, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = labels[index], + style = styles[index], + color = color, + textAlign = TextAlign.Center, + ) } - val chip = highlight.value - drawRoundRect(White, chip.topLeft, chip.size, CornerRadius(chip.height / 2), alpha = shown.value) - }, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { - options.forEach { option -> - val isSelected = option == selected - val color by animateColorAsState( - targetValue = if (isSelected) Color.Black else CodeTheme.colors.textMain, - animationSpec = chipMotion(reducedMotion), - label = "chip text", - ) - Text( - modifier = Modifier - .onPlaced { bounds[option] = Rect(it.positionInParent(), it.size.toSize()) } - .clip(CircleShape) - .selectable(selected = isSelected, role = Role.RadioButton) { onSelect(option) } - .padding(horizontal = 10.dp, vertical = 7.dp), - text = label(option), - style = CodeTheme.typography.caption, - color = color, - maxLines = 1, - ) + } } } } +private val ChipPadding = 6.dp + +/** + * The largest of [style]'s size down to 80% at which [text] fits [width] on one line; failing + * that, 80%, at which the label wraps. + */ +private fun fitStyle(text: String, style: TextStyle, width: Int, measurer: TextMeasurer): TextStyle { + val sizes = (100 downTo 80 step 2).map { style.copy(fontSize = style.fontSize * (it / 100f)) } + return sizes.firstOrNull { candidate -> + val layout = measurer.measure( + text = text, + style = candidate, + maxLines = 1, + softWrap = false, + constraints = Constraints(maxWidth = width.coerceAtLeast(0)), + ) + !layout.didOverflowWidth + } ?: sizes.last() +} + private fun chipMotion(reducedMotion: Boolean): AnimationSpec = if (reducedMotion) snap() else spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow) diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt index e85f097807..e4eeb430a0 100644 --- a/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt @@ -1,25 +1,41 @@ package com.getcode.ui.components +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import com.getcode.theme.DesignSystem import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import kotlin.test.assertEquals +import kotlin.test.assertTrue +// Wide enough for the widest row the tests lay out. +@Config(qualifiers = "w411dp-h891dp") +// Measures real text, so the fit test sees the labels' true widths. +@GraphicsMode(GraphicsMode.Mode.NATIVE) @RunWith(RobolectricTestRunner::class) class SlidingChipRowTest { @@ -80,4 +96,81 @@ class SlidingChipRowTest { } options.forEach { composeTestRule.onNodeWithText(it).assertIsNotSelected() } } + + @Test + fun `the chips share the row's width equally`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow( + options = listOf("1", "5", "10", "25", "No limit"), + selected = "1", + onSelect = {}, + label = { it }, + modifier = Modifier.width(371.dp), + ) + } + } + val widths = listOf("1", "5", "10", "25", "No limit") + .map { composeTestRule.onNodeWithText(it).getBoundsInRoot().let { b -> b.right - b.left } } + // Equal to the pixel: the row's 347dp of chips doesn't divide by five exactly. + assertTrue(widths.max() - widths.min() <= 1.dp, "chip widths $widths") + // Five chips and four 6dp gaps fill the row. + val filled = widths.reduce { a, b -> a + b } + 24.dp + assertTrue(filled in 370.dp..372.dp, "row filled to $filled") + } + + @Test + fun `no label is truncated at font scales up to 2 on a 411dp or a 360dp phone`() { + var case by mutableStateOf(Triple(labelSets.first(), 371, 1f)) + composeTestRule.setContent { + val density = LocalDensity.current + val (labels, rowWidth, fontScale) = case + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale)) { + DesignSystem { + SlidingChipRow( + options = labels, + selected = labels.first(), + onSelect = {}, + label = { it }, + modifier = Modifier.width(rowWidth.dp), + ) + } + } + } + // The row is the phone's width less the 20dp inset on each side. + for (rowWidth in listOf(371, 320)) { + for (fontScale in listOf(1f, 1.5f, 2f)) { + for (labels in labelSets) { + case = Triple(labels, rowWidth, fontScale) + composeTestRule.waitForIdle() + val where = "in a ${rowWidth}dp row at font scale $fontScale" + labels.forEach { label -> + val layout = textLayout(label) + val shown = label.substring(0, layout.getLineEnd(layout.lineCount - 1, visibleEnd = true)) + val widest = (0 until layout.lineCount).maxOf { layout.getLineRight(it) - layout.getLineLeft(it) } + assertTrue( + shown == label && !layout.didOverflowHeight && + widest <= layout.layoutInput.constraints.maxWidth, + "\"$label\" shows \"$shown\" $where", + ) + } + val heights = labels.map { composeTestRule.onNodeWithText(it).getBoundsInRoot().let { b -> b.bottom - b.top } } + assertEquals(1, heights.distinct().size, "chip heights $heights $where") + } + } + } + } + + private val labelSets = listOf( + listOf("1 day", "7 days", "30 days", "Never"), + listOf("1", "5", "10", "25", "No limit"), + listOf("1 Day", "3 Days", "1 Week", "2 Weeks", "4 Weeks"), + ) + + private fun textLayout(label: String): TextLayoutResult { + val results = mutableListOf() + composeTestRule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode() + .config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return results.single() + } } From 1f758121a3ad969c0af73ea4f456f67b41e3e36d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:54:32 -0400 Subject: [PATCH 19/20] fix(messenger): present New Invite Link on the app's sheet scene New Invite Link on Manage Invites was a raw Material3 ModalBottomSheet, so it showed a drag handle and a left-aligned title with no close button, unlike Mute Chat and the invite link row sheet. Make it ChatStep.NewInviteLink, a WrapContentSheet with the same AppBarWithTitle header (centred title, Close). It returns the chosen expiry and use limit as a NewInviteLinkChoice, which holds preset names only, and closes. Manage Invites creates the link from the result; the New Link button (and the empty-state button) show a loading state while ManageInvitesViewModel.creating is set. Error handling is unchanged. The Created effect is gone, since there is no sheet left to close. --- .../com/flipcash/app/core/chat/ChatStep.kt | 21 +++ .../core/src/main/res/values/strings.xml | 2 +- .../flipcash/app/messenger/ChatFlowScreen.kt | 20 +++ .../internal/invites/InvitePreviews.kt | 14 +- .../internal/invites/ManageInvitesScreen.kt | 130 ++++++------------ .../invites/ManageInvitesViewModel.kt | 6 +- .../invites/InviteFramesScreenshotTest.kt | 35 ++++- .../invites/ManageInvitesViewModelTest.kt | 22 ++- .../invites/NewInviteLinkChoiceTest.kt | 34 +++++ 9 files changed, 185 insertions(+), 99 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/NewInviteLinkChoiceTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 2f31a24302..917cada750 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -33,6 +33,14 @@ data class GroupInviteResult(val chatId: ChatId) : Parcelable @Serializable enum class InviteLinkAction : Parcelable { Share, Revoke } +/** + * What the host picked on New Invite Link (frame 2d): the preset names, not the presets, because + * this module cannot see `shared:chat`. Presets only; never a key or link. + */ +@Parcelize +@Serializable +data class NewInviteLinkChoice(val expiry: String, val uses: String) : Parcelable + /** A custom paid-reaction amount, in whole US cents. */ @Parcelize @Serializable @@ -123,6 +131,19 @@ sealed interface ChatStep : FlowStep, Parcelable { Sheet, WrapContentSheet + /** + * Frame 2d: expiry and use-limit presets for a new invite link. Returns the chosen + * [NewInviteLinkChoice] and closes; Manage Invites, still beneath it, makes the link and shows + * the creating state. The result is presets only, never a key or link. + */ + @Parcelize + @Serializable + data object NewInviteLink : + ChatStep, + NavigationRetVal, + Sheet, + WrapContentSheet + /** Frame 2a: the invite links this host has made, Active then Inactive. Pushed, like [EditGroup]. */ @Parcelize @Serializable diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index ee9c5d5424..240941398b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1520,7 +1520,7 @@ 7 days 30 days Never - 1 person + 1 5 10 25 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 27e97ea85d..ad25d04d63 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -39,7 +39,10 @@ import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.core.chat.InviteLinkAction +import com.flipcash.app.core.chat.NewInviteLinkChoice import com.flipcash.app.messenger.internal.invites.InviteLinkActions +import com.flipcash.app.messenger.internal.invites.NewInviteLinkContent +import com.flipcash.app.messenger.internal.invites.toChoice import com.flipcash.app.messenger.internal.invites.InviteLinkActionsViewModel import com.flipcash.app.messenger.internal.invites.InviteLinkViewModel import com.flipcash.app.messenger.internal.invites.ManageInvitesScreen @@ -179,6 +182,9 @@ private fun chatEntryProvider( annotatedEntry { step -> FlowInviteLinkSheet(step.inviteIdHex) } + annotatedEntry { + FlowNewInviteLinkSheet() + } annotatedEntry { step -> FlowInviteLinkActionsSheet(step.inviteIdHex) } @@ -618,6 +624,20 @@ private fun FlowInviteLinkActionsSheet(inviteIdHex: String) { ) } +/** + * [ChatStep.NewInviteLink]: frame 2d on the app's sheet scene. It returns the chosen settings and + * closes; Manage Invites, still beneath it, makes the link and shows the creating state. + */ +@Composable +private fun FlowNewInviteLinkSheet() { + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) + NewInviteLinkContent( + onCreate = { resultBack.returnValue(it.toChoice()) }, + onDismiss = dismissSheet, + ) +} + /** * [ChatStep.ShareMessage]: the invite sheet handing out one message's link. Same send path and * result as [FlowGroupInviteSheet]; the link is the whole subject. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt index 4b10b82b8c..0285f6e0cd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt @@ -19,7 +19,9 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Invite import com.flipcash.services.models.chat.InviteId import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.InviteExpiry import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.manager.BottomBarManager import com.getcode.ui.components.bars.BottomBarView @@ -200,7 +202,17 @@ internal fun InviteFrame2cRevoke() = BottomBarFrame { showRevokeInviteLink(it) { @PreviewWrapper(FlipcashThemeWrapper::class) @Composable internal fun InviteFrame2dNewLink() = - NewInviteLinkContent(creating = false, onCreate = {}, initial = InviteSettings.Default) + NewInviteLinkContent(onCreate = {}, onDismiss = {}, initial = InviteSettings.Default) + +/** Frame 2d with presets other than the defaults chosen, so the chip highlight shows off its start. */ +@Preview(heightDp = 560) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2dNewLinkChosen() = NewInviteLinkContent( + onCreate = {}, + onDismiss = {}, + initial = InviteSettings(InviteExpiry.ThirtyDays, InviteUses.Ten), +) /** Frame 2e, node 12844:395. */ @Preview(heightDp = 817) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt index 1cbb45574a..64e0e57803 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt @@ -6,15 +6,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape @@ -25,8 +22,6 @@ import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Block import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,7 +35,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -48,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.InviteLinkAction +import com.flipcash.app.core.chat.NewInviteLinkChoice import com.flipcash.app.messenger.internal.screens.openInviteLinkShare import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId @@ -55,6 +50,7 @@ import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.InviteExpiry import com.flipcash.shared.chat.InviteSettings import com.flipcash.shared.chat.InviteUses +import com.flipcash.shared.chat.ui.media.rememberReducedMotion import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.navigation.results.NavResultOrCanceled @@ -65,6 +61,7 @@ import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.ChoiceRow +import com.getcode.ui.components.SlidingChipRow import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeCircularProgressIndicator @@ -84,14 +81,11 @@ internal fun ManageInvitesScreen(chatId: ChatId?) { LaunchedEffect(chatId) { chatId?.let(viewModel::open) } - var newLink by remember { mutableStateOf(false) } - LaunchedEffect(viewModel) { viewModel.effects.collect { effect -> when (effect) { // Back to the profile that opened this. ManageInvitesViewModel.Effect.LostAccess -> flowNavigator.back() - ManageInvitesViewModel.Effect.Created -> newLink = false } } } @@ -111,17 +105,15 @@ internal fun ManageInvitesScreen(chatId: ChatId?) { } } }, - onNewLink = { newLink = true }, + onNewLink = { + // The settings sheet is a step of its own and closes on Create Link; this screen makes + // the link and shows the creating state. + navigator.navigateForResult(ChatStep.NewInviteLink) { result -> + if (result is NavResultOrCanceled.ReturnValue) viewModel.create(result.value.toSettings()) + } + }, onRetry = viewModel::refresh, ) - - if (newLink) { - NewInviteLinkSheet( - creating = state.creating, - onCreate = viewModel::create, - onDismiss = { newLink = false }, - ) - } } /** Frames 2a and 2e, with no navigation or view model, so the previews and tests can draw them. */ @@ -133,6 +125,7 @@ internal fun ManageInvitesContent( onNewLink: () -> Unit, onRetry: () -> Unit = {}, ) { + val creating = state.creating CodeScaffold( topBar = { AppBarWithTitle( @@ -158,7 +151,7 @@ internal fun ManageInvitesContent( } else if (state.failed) { LoadFailed(modifier = Modifier.align(Alignment.Center), onRetry = onRetry) } else if (state.isEmpty) { - EmptyInvites(modifier = Modifier.align(Alignment.Center), onNewLink = onNewLink) + EmptyInvites(modifier = Modifier.align(Alignment.Center), creating = creating, onNewLink = onNewLink) } else { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -199,6 +192,8 @@ internal fun ManageInvitesContent( .padding(horizontal = CodeTheme.dimens.inset) .padding(bottom = CodeTheme.dimens.grid.x3), text = stringResource(R.string.action_newInviteLink), + isLoading = creating, + enabled = !creating, buttonState = ButtonState.Filled, onClick = onNewLink, ) @@ -330,7 +325,7 @@ private fun LoadFailed(onRetry: () -> Unit, modifier: Modifier = Modifier) { /** Frame 2e. */ @Composable -private fun EmptyInvites(onNewLink: () -> Unit, modifier: Modifier = Modifier) { +private fun EmptyInvites(creating: Boolean, onNewLink: () -> Unit, modifier: Modifier = Modifier) { Column( modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), horizontalAlignment = Alignment.CenterHorizontally, @@ -352,6 +347,8 @@ private fun EmptyInvites(onNewLink: () -> Unit, modifier: Modifier = Modifier) { CodeButton( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = stringResource(R.string.action_newInviteLink), + isLoading = creating, + enabled = !creating, buttonState = ButtonState.Filled10, shape = CircleShape, style = CodeTheme.typography.textSmall, @@ -409,112 +406,69 @@ internal fun InviteLinkActions( } } -/** Frame 2d: expiry and use-limit presets, and Create Link. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun NewInviteLinkSheet( - creating: Boolean, - onCreate: (InviteSettings) -> Unit, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = CodeTheme.colors.background, - ) { - NewInviteLinkContent(creating = creating, onCreate = onCreate) - } -} - +/** Frame 2d: expiry and use-limit presets, and Create Link. Returns the pick; the caller creates. */ @Composable internal fun NewInviteLinkContent( - creating: Boolean, onCreate: (InviteSettings) -> Unit, + onDismiss: () -> Unit, initial: InviteSettings = InviteSettings.Default, ) { var expiry by remember { mutableStateOf(initial.expiry) } var uses by remember { mutableStateOf(initial.uses) } - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = CodeTheme.dimens.inset), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - Text( - text = stringResource(R.string.title_newInviteLink), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, + val reducedMotion = rememberReducedMotion() + Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + AppBarWithTitle( + title = stringResource(R.string.title_newInviteLink), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { Text( text = stringResource(R.string.label_inviteExpiresHeader), style = CodeTheme.typography.textSmall, color = White50, ) - OptionChips( + SlidingChipRow( options = InviteExpiry.entries, selected = expiry, - label = { stringResource(it.labelRes()) }, onSelect = { expiry = it }, + label = { stringResource(it.labelRes()) }, + reducedMotion = reducedMotion, ) Text( text = stringResource(R.string.label_inviteWhoCanJoin), style = CodeTheme.typography.textSmall, color = White50, ) - OptionChips( + SlidingChipRow( options = InviteUses.entries, selected = uses, - label = { stringResource(it.labelRes()) }, onSelect = { uses = it }, + label = { stringResource(it.labelRes()) }, + reducedMotion = reducedMotion, ) CodeButton( modifier = Modifier .fillMaxWidth() .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x3), text = stringResource(R.string.action_createInviteLink), - isLoading = creating, - enabled = !creating, buttonState = ButtonState.Filled, onClick = { onCreate(InviteSettings(expiry, uses)) }, ) - } -} - -@Composable -private fun OptionChips( - options: List, - selected: T, - label: @Composable (T) -> String, - onSelect: (T) -> Unit, -) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - for (option in options) { - val isSelected = option == selected - // The chip is a radio button in a group, and at least 48dp tall to touch. - Box( - modifier = Modifier - .heightIn(min = 48.dp) - .clip(CircleShape) - .background(if (isSelected) CodeTheme.colors.textMain else White05) - .selectable(selected = isSelected, role = Role.RadioButton, onClick = { onSelect(option) }) - .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x2), - contentAlignment = Alignment.Center, - ) { - Text( - text = label(option), - style = CodeTheme.typography.textSmall, - color = if (isSelected) CodeTheme.colors.background else CodeTheme.colors.textMain, - ) - } } } } +internal fun InviteSettings.toChoice() = NewInviteLinkChoice(expiry = expiry.name, uses = uses.name) + +internal fun NewInviteLinkChoice.toSettings() = + InviteSettings(expiry = InviteExpiry.valueOf(expiry), uses = InviteUses.valueOf(uses)) + private fun InviteExpiry.labelRes(): Int = when (this) { InviteExpiry.OneDay -> R.string.label_inviteExpiry_oneDay InviteExpiry.SevenDays -> R.string.label_inviteExpiry_sevenDays diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt index a9c48d0768..9580f6fe5e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt @@ -46,9 +46,6 @@ internal class ManageInvitesViewModel @Inject constructor( sealed interface Effect { /** The viewer lost `can_invite`; back to the profile. */ data object LostAccess : Effect - - /** A link was made; close the New Link sheet. */ - data object Created : Effect } private val _state = MutableStateFlow(State()) @@ -105,7 +102,7 @@ internal class ManageInvitesViewModel @Inject constructor( } } - /** Frame 2d's Create Link. The new link becomes the sheet's current link. */ + /** Frame 2d's Create Link, with the settings the sheet returned. The new link becomes the current one. */ fun create(settings: InviteSettings) { val chatId = chatId ?: return if (_state.value.creating) return @@ -115,7 +112,6 @@ internal class ManageInvitesViewModel @Inject constructor( is InviteCreation.Created -> { _state.update { it.copy(creating = false) } publish(chatId) - _effects.send(Effect.Created) } InviteCreation.TooMany -> { _state.update { it.copy(creating = false) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt index 6e9d3cf141..a0325448f5 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt @@ -5,7 +5,13 @@ import android.graphics.Canvas import android.view.View import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashPreview import org.junit.Rule import org.junit.Test @@ -13,11 +19,12 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertTrue import java.io.File /** * Renders every invite frame's preview to `build/screenshots/invite_.png`, to be compared - * with the design by eye. Not an assertion test; it fails only if a frame throws. + * with the design by eye. Apart from 2d's preset chips, it fails only if a frame throws. * * Same mechanics as the group chrome screenshot test: pause the clock, pump a fixed number of * frames and draw the view directly, so a composable that keeps scheduling frames can't hang. @@ -53,6 +60,30 @@ class InviteFramesScreenshotTest { @Test fun frame2a() = render("2a") { InviteFrame2aManage() } @Test fun frame2b() = render("2b") { InviteFrame2bRowSheet() } @Test fun frame2c() = render("2c") { InviteFrame2cRevoke() } - @Test fun frame2d() = render("2d") { InviteFrame2dNewLink() } + @Test fun frame2d() { + render("2d") { InviteFrame2dNewLink() } + // The spec's Uses presets are bare counts (1, 5, 10, 25), not "1 person". + listOf("1", "5", "10", "25", "No limit").forEach { + composeRule.onNodeWithText(it).assertExists() + } + composeRule.onNodeWithText("1 person").assertDoesNotExist() + // Each row's chips share its width equally, as on iOS. + listOf(listOf("1 day", "7 days", "30 days", "Never"), listOf("1", "5", "10", "25", "No limit")) + .forEach { row -> + val widths = row.map { composeRule.onNodeWithText(it).getBoundsInRoot().let { b -> b.right - b.left } } + assertTrue(widths.max() - widths.min() <= 1.dp, "chip widths $widths") + } + } + + // Android's largest font scale. The chips cut a label at its word break rather than shrink it, + // so this shows "30" and "No" for "30 days" and "No limit". + @Test fun frame2dLargestFont() = render("2d-fontScale2") { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 2f)) { + InviteFrame2dNewLink() + } + } + + @Test fun frame2dChosen() = render("2d-chosen") { InviteFrame2dNewLinkChosen() } @Test fun frame2e() = render("2e") { InviteFrame2eEmpty() } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt index ac41bf3f94..be8c1fcbc0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt @@ -8,7 +8,9 @@ import com.flipcash.services.models.chat.InviteId import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.InviteCreation +import com.flipcash.shared.chat.InviteExpiry import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery @@ -154,7 +156,7 @@ class ManageInvitesViewModelTest { } @Test - fun `revoking asks first and a created link closes the new link sheet`() = runTest { + fun `revoking asks first, and a created link clears the creating state`() = runTest { val active = hostInvite() coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) val created = hostInvite() @@ -166,10 +168,26 @@ class ManageInvitesViewModelTest { assertTrue(BottomBarManager.messages.value.single().showCancel) model.create(InviteSettings.Default) - assertTrue(model.effects.first() is ManageInvitesViewModel.Effect.Created) assertFalse(model.state.value.creating) } + @Test + fun `create sends the settings the sheet returned and shows creating until it finishes`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + val gate = kotlinx.coroutines.CompletableDeferred() + val settings = InviteSettings(InviteExpiry.OneDay, InviteUses.Five) + coEvery { hostInvites.create(chatId, settings) } coAnswers { gate.await() } + val model = viewModel() + model.open(chatId) + + model.create(settings) + assertTrue(model.state.value.creating) + + gate.complete(InviteCreation.Created(hostInvite())) + assertFalse(model.state.value.creating) + coVerify(exactly = 1) { hostInvites.create(chatId, settings) } + } + @Test fun `a created link becomes the current one`() = runTest { val created = hostInvite() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/NewInviteLinkChoiceTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/NewInviteLinkChoiceTest.kt new file mode 100644 index 0000000000..a6839ee88b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/NewInviteLinkChoiceTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.messenger.internal.invites + +import android.os.Parcel +import com.flipcash.app.core.chat.NewInviteLinkChoice +import com.flipcash.shared.chat.InviteExpiry +import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** New Invite Link returns its pick as a [NewInviteLinkChoice], so every preset must survive a Parcel and map back. */ +@RunWith(RobolectricTestRunner::class) +class NewInviteLinkChoiceTest { + + @Test + fun `every expiry and use limit round-trips through a parcel and back to settings`() { + for (expiry in InviteExpiry.entries) { + for (uses in InviteUses.entries) { + val settings = InviteSettings(expiry, uses) + val parcel = Parcel.obtain() + try { + parcel.writeParcelable(settings.toChoice(), 0) + parcel.setDataPosition(0) + val read = parcel.readParcelable(NewInviteLinkChoice::class.java.classLoader) + assertEquals(settings, read!!.toSettings()) + } finally { + parcel.recycle() + } + } + } + } +} From b1f9b3eb7a011b55ad10cd6e57c08b39716930f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 20:03:04 -0400 Subject: [PATCH 20/20] test(messenger): render Time To Pay's preset chips at both font scales Only the chip row: the sheet is a ModalBottomSheet, whose window the view-drawing harness doesn't capture. At font scale 2 each chip shows only its number, so 1 Day and 1 Week read the same. --- .../internal/request/TimeToPaySheet.kt | 2 +- .../request/TimeToPayChipsScreenshotTest.kt | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayChipsScreenshotTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt index 181c15d464..364966579d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt @@ -546,7 +546,7 @@ private fun TimeDialog(initial: LocalTime, onPicked: (LocalTime) -> Unit, onDism } } -private val Deadline.Preset.label: Int +internal val Deadline.Preset.label: Int get() = when (this) { Deadline.Preset.Day -> R.string.option_timeToPay_day Deadline.Preset.ThreeDays -> R.string.option_timeToPay_threeDays diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayChipsScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayChipsScreenshotTest.kt new file mode 100644 index 0000000000..92e6442d2d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayChipsScreenshotTest.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.messenger.internal.request + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.Density +import com.flipcash.app.messenger.internal.request.NewRequestDraft.Deadline +import com.flipcash.app.theme.FlipcashPreview +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SlidingChipRow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders Time To Pay's preset chips to `build/screenshots/timeToPay_chips_.png`. Only + * the chip row: the sheet itself is a ModalBottomSheet, whose window this view-drawing harness + * doesn't capture. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [32], qualifiers = "w411dp-h891dp-xxhdpi") +class TimeToPayChipsScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun render(fontScale: Float) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale)) { + FlipcashPreview(showBackground = true) { + SlidingChipRow( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + options = Deadline.Preset.entries, + selected = Deadline.Preset.Week, + onSelect = {}, + label = { stringResource(it.label) }, + ) + } + } + } + repeat(12) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val file = File(File("build/screenshots").apply { mkdirs() }, "timeToPay_chips_$fontScale.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath}") + } + + @Test fun defaultFont() = render(1f) + + // Android's largest font scale: "2 Weeks" and "4 Weeks" are cut to "2" and "4". + @Test fun largestFont() = render(2f) +} +