From 8d29ce71c8d0089ac45680effc5e2194b01ff664 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 13:30:08 -0400 Subject: [PATCH 01/25] feat(groups): create a private group New Chat gets a "Create a Private Group" row (node 11203:59) that opens the public group form without Minimum Balance, with the private-group footnote under the name (node 11203:65). Create is two calls: StartChat(private_group), then SetKeyEnvelope with the creator's own envelope. If the envelope fails, it is retried three times; after that the next Create goes straight back to the envelope rather than starting a second chat. A finished create opens the group's profile, not the chat. ChatCipher gains the KeyEnvelope X25519_XCHACHA20POLY1305 wrap, following chat/v1/model.proto steps 1-4. It shares the DM key's X25519 + HKDF steps under the flipcash-group-key-wrap-v1 label. There are no shared vectors for it yet, so GroupKeyWrapTest checks round trips and rejections rather than bytes. GroupKeyEnvelopes owns the key: generate and store on create, wrap for a recipient on approve. ALREADY_SET counts as success, since a retry after a lost response lands there. --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 8 +- .../core/src/main/res/values/strings.xml | 3 + .../com/flipcash/app/tipping/NewChatScreen.kt | 7 +- .../app/tipping/NewGroupFlowScreen.kt | 16 +- .../tipping/internal/CreateGroupViewModel.kt | 109 ++++++++++++- .../tipping/internal/GroupCreateAttempt.kt | 3 +- .../internal/screens/NewGroupFormScreen.kt | 62 +++++--- .../internal/CreateGroupViewModelErrorTest.kt | 2 + .../CreatePrivateGroupViewModelTest.kt | 149 ++++++++++++++++++ .../services/chat/GroupKeyEnvelopes.kt | 88 +++++++++++ .../services/chat/GroupKeyEnvelopesTest.kt | 90 +++++++++++ 11 files changed, 502 insertions(+), 35 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreatePrivateGroupViewModelTest.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 4c351dcc6c..f1226817e1 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -412,14 +412,18 @@ sealed interface AppRoute : NavKey, Parcelable { data object FindByUsername : Messaging /** - * Node 10153:22901 — creating a public group, from the empty form to its invite link. + * Node 10153:22901 — creating a public group, from the empty form to its invite link; node + * 11203:65 is the same flow for a private one. * * The three screens share one draft, so they are steps of a flow rather than routes; see * [NewGroupStep]. */ @Serializable @Parcelize - data object NewGroup : Messaging, FlowRoute { + data class NewGroup( + /** Node 11203:65: approval to join, end-to-end encrypted, no balance requirement. */ + val isPrivate: Boolean = false, + ) : Messaging, FlowRoute { override val initialStack: List get() = listOf(NewGroupStep.Form) } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 978e5790ce..e59c09095c 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1104,6 +1104,9 @@ (node 10127:117987). --> New Chat Create a Public Group + Create a Private Group + New Private Group + Only people you approve can join. Messages are end-to-end encrypted. This can\'t be changed later. Find Someone by Username diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt index c10ed9295a..4b5291a301 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt @@ -53,9 +53,14 @@ fun NewChatScreen() { icon = R.drawable.ic_group_3, onClick = { analytics.track(GroupEvents.newOpened()) - navigator.push(AppRoute.Messaging.NewGroup) + navigator.push(AppRoute.Messaging.NewGroup()) }, ) + ChoiceRow( + label = stringResource(R.string.action_createPrivateGroup), + icon = R.drawable.ic_lock, + onClick = { navigator.push(AppRoute.Messaging.NewGroup(isPrivate = true)) }, + ) } } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt index 29bd185e8b..a648159ec6 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt @@ -2,6 +2,7 @@ package com.flipcash.app.tipping import android.os.Parcelable import androidx.compose.runtime.Composable +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +60,12 @@ fun NewGroupFlowScreen( // to reach it. var createdChat by remember { mutableStateOf(null) } + // Created here, before any step asks for it, so the steps' flowSharedViewModel resolves this + // instance — the flow's store owner is this entry's — and it is born knowing which form it is. + hiltViewModel( + creationCallback = { factory -> factory.create(route.isPrivate) }, + ) + FlowHost( initialStack = route.rememberInitialStack(), resultStateRegistry = resultStateRegistry, @@ -78,7 +85,14 @@ fun NewGroupFlowScreen( navigator.popUntil { it !is AppRoute.Messaging.NewGroup && it !is AppRoute.Messaging.NewChat } - navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + // A private group opens on its profile (surface 6): nothing can happen in the + // chat until the host approves someone, and the profile is where requests are. + navigator.push( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(chatId), + openOnProfile = route.isPrivate, + ) + ) } } }, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index 8f34303b26..be47457f2b 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -14,6 +14,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.tipping.R import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.chat.GroupKeyEnvelopes import com.flipcash.services.models.BlobRejectedException import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.BlobId @@ -41,7 +42,11 @@ import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull @@ -50,7 +55,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import javax.inject.Inject /** * The three preset balance requirements on the form — node 10127:118014's `$10 / $50 / $100` chips. @@ -75,9 +79,14 @@ internal val BalancePresets: List = listOf(Fiat(10), Fiat(50), Fiat(100)) * `StartChat` would answer `RULES_NOT_SATISFIED`, which is a worse way to learn it. And the picture * has to be uploaded and `READY` first, because `PublicGroupChatParameters.picture` is a blob id, not * bytes. Both server results are still handled: a balance can move between the check and the call. + * + * [isPrivate] is the private group form (node 11203:65): the same draft without a requirement, and + * a second step after `StartChat` — the creator's key envelope — that has to land before anything + * can happen in the group. */ -@HiltViewModel -internal class CreateGroupViewModel @Inject constructor( +@HiltViewModel(assistedFactory = CreateGroupViewModel.Factory::class) +internal class CreateGroupViewModel @AssistedInject constructor( + @Assisted isPrivate: Boolean, exchange: Exchange, tokenCoordinator: TokenCoordinator, dispatchers: DispatcherProvider, @@ -87,13 +96,16 @@ internal class CreateGroupViewModel @Inject constructor( private val contentReader: ContentReader, private val resources: ResourceHelper, private val analytics: FlipcashAnalytics, + private val groupKeys: GroupKeyEnvelopes, ) : BaseViewModel( - initialState = State(), + initialState = State(isPrivate = isPrivate), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { data class State( + /** Fixed for the flow: privacy can't change after creation, so it is not a form field. */ + val isPrivate: Boolean = false, val titleFieldState: TextFieldState = TextFieldState(), /** The picked picture's re-encoded local copy, while it is being prepared and after. */ val image: Loadable = Loadable.Loading(), @@ -154,7 +166,7 @@ internal class CreateGroupViewModel @Inject constructor( * sets; `speaker` is left empty rather than null because [ChatRules] holds both as lists. */ val rules: ChatRules? - get() = amount?.let { rulesFor(currency, it) } + get() = if (isPrivate) null else amount?.let { rulesFor(currency, it) } /** * The creator measured against their own rule, by the same predicate the join gate uses. @@ -187,7 +199,7 @@ internal class CreateGroupViewModel @Inject constructor( get() = access.let { it == null || it is GroupAccess.Eligible } val canCreate: Boolean - get() = hasTitle && rules != null && selfSatisfied && processingState.isIdle + get() = hasTitle && processingState.isIdle && (isPrivate || (rules != null && selfSatisfied)) /** * The amount shown in the `…` chip's place, when one was entered there. @@ -256,6 +268,12 @@ internal class CreateGroupViewModel @Inject constructor( private val attempt = GroupCreateAttempt() + /** + * A private group `StartChat` created whose key envelope has not landed yet. Create resumes + * here rather than calling `StartChat` again: the chat exists, only its key is missing. + */ + private var awaitingKey: ChatMetadata? = null + init { exchange.observePreferredRate() .distinctUntilChanged() @@ -340,9 +358,14 @@ internal class CreateGroupViewModel @Inject constructor( private suspend fun create() { val state = stateFlow.value if (!state.processingState.isIdle) return + if (!state.hasTitle) return + + if (state.isPrivate) { + createPrivate(state) + return + } val amount = state.amount ?: return - if (!state.hasTitle) return // The self-satisfaction check the form already gates Create on, re-read here because the // balance behind it is a live flow and the tap is a separate moment from the render. @@ -403,6 +426,70 @@ internal class CreateGroupViewModel @Inject constructor( } } + /** + * `StartChat(private_group)`, then the creator's key envelope. Create stays loading through + * both. A failed envelope is retried a few times here, and after that by the next Create, which + * goes straight back to the envelope. + */ + private suspend fun createPrivate(state: State) { + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + val chat = awaitingKey ?: run { + val draft = GroupDraft( + title = state.title, + picture = state.image.dataOrNull, + currency = state.currency, + amount = null, + ) + val idempotencyKey = attempt.keyFor(draft) + + val picture = if (draft.picture == null) { + null + } else { + attempt.picture ?: uploadPicture(draft.picture, state.imageMimeType) + ?.also { attempt.rememberPicture(it) } + ?: run { + dispatchEvent(Event.UpdateProcessingState()) + return + } + } + + chatCoordinator.create( + parameters = StartChatParameters.PrivateGroup(title = draft.title, picture = picture), + idempotencyKey = idempotencyKey, + ).also { analytics.trackCreated(null, hasPicture = picture != null, it) } + .onSuccess { attempt.clear() } + .getOrElse { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceCreateFailure(cause, currencyName = null) + return + } + } + + awaitingKey = chat + if (storeKeyWithRetry(chat)) { + awaitingKey = null + dispatchSuccessThen(Event.UpdateProcessingState(success = true)) { + dispatchEvent(Event.ChatCreated(chat)) + dispatchEvent(Event.UpdateProcessingState()) + } + } else { + dispatchEvent(Event.UpdateProcessingState()) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupCreateFailed), + message = resources.getString(R.string.error_description_groupCreateFailed), + ) + } + } + + private suspend fun storeKeyWithRetry(chat: ChatMetadata): Boolean { + repeat(KEY_ATTEMPTS) { index -> + if (groupKeys.storeNewKey(chat.chatId).isSuccess) return true + if (index < KEY_ATTEMPTS - 1) delay(KEY_RETRY_DELAY_MS * (index + 1)) + } + return false + } + /** Uploads the prepared picture and returns its READY blob, or null having reported why not. */ private suspend fun uploadPicture(uri: Uri, mimeType: String): BlobId? { val bytes = contentReader.readBytes(uri) @@ -510,7 +597,15 @@ internal class CreateGroupViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun create(isPrivate: Boolean): CreateGroupViewModel + } + companion object { + private const val KEY_ATTEMPTS = 3 + private const val KEY_RETRY_DELAY_MS = 500L + /** * The stored requirement for a keypad [entered] amount, or null when it falls below the * currency's minimum transfer value (one unit of its last decimal place). diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt index 4764314d03..8cdb64bb25 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt @@ -21,7 +21,8 @@ internal data class GroupDraft( */ val currency: GroupCurrency, /** The required balance, in USD — the currency [com.flipcash.shared.chat.GroupAccess] compares in. */ - val amount: Fiat, + /** Null for a private group, which has no requirement. */ + val amount: Fiat?, ) /** diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt index fb01f0c084..f7c25b072a 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt @@ -88,7 +88,9 @@ internal fun NewGroupFormScreen(viewModel: CreateGroupViewModel) { Column(modifier = Modifier.fillMaxSize()) { AppBarWithTitle( - title = stringResource(R.string.title_newPublicGroup), + title = stringResource( + if (state.isPrivate) R.string.title_newPrivateGroup else R.string.title_newPublicGroup + ), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.exitCanceled() } }, endContent = { @@ -113,30 +115,44 @@ internal fun NewGroupFormScreen(viewModel: CreateGroupViewModel) { .imePadding(), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x9), ) { - IdentityCard( - image = state.image, - titleField = { - DisplayTextInput( - state = state.titleFieldState, - placeholder = stringResource(R.string.hint_groupName), - modifier = Modifier.fillMaxWidth(), - style = CodeTheme.typography.screenTitle.copy( - color = CodeTheme.colors.textMain, - ), - placeholderStyle = CodeTheme.typography.screenTitle.copy( - color = CodeTheme.colors.textMain.copy(alpha = 0.2f), - ), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Words, - imeAction = ImeAction.Done, - ), - onKeyboardAction = { keyboard.hideIfVisible { } }, + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2)) { + IdentityCard( + image = state.image, + titleField = { + DisplayTextInput( + state = state.titleFieldState, + placeholder = stringResource(R.string.hint_groupName), + modifier = Modifier.fillMaxWidth(), + style = CodeTheme.typography.screenTitle.copy( + color = CodeTheme.colors.textMain, + ), + placeholderStyle = CodeTheme.typography.screenTitle.copy( + color = CodeTheme.colors.textMain.copy(alpha = 0.2f), + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { keyboard.hideIfVisible { } }, + ) + }, + onPick = { pickPicture.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }, + ) + + // Node 11203:65: what a private group is, said once, where privacy is chosen, because + // it can't be undone. + if (state.isPrivate) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.subtitle_privateGroupFootnote), + style = CodeTheme.typography.caption.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = White50, ) - }, - onPick = { pickPicture.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }, - ) + } + } - RequirementSection( + // A private group has no rules (StartChat's private_group case carries none). + if (!state.isPrivate) RequirementSection( state = state, onChangeCurrency = { keyboard.hideIfVisible { diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt index 2bb663b106..e2d9515092 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt @@ -97,6 +97,7 @@ class CreateGroupViewModelErrorTest { } private fun createViewModel() = CreateGroupViewModel( + isPrivate = false, exchange = exchange, tokenCoordinator = tokenCoordinator, dispatchers = dispatchers, @@ -106,6 +107,7 @@ class CreateGroupViewModelErrorTest { contentReader = contentReader, resources = resources, analytics = analytics, + groupKeys = mockk(relaxed = true), ) private fun token(seed: Byte, symbol: String) = MintMetadata( diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreatePrivateGroupViewModelTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreatePrivateGroupViewModelTest.kt new file mode 100644 index 0000000000..57e114ec58 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreatePrivateGroupViewModelTest.kt @@ -0,0 +1,149 @@ +package com.flipcash.app.tipping.internal + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.tipping.R +import com.flipcash.services.chat.GroupKeyEnvelopes +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.StartChatParameters +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.FakeResourceHelper +import com.getcode.view.SuccessHoldDuration +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Creating a private group (node 11203:65): `StartChat(private_group)`, then the creator's key + * envelope, with only the second step retried. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CreatePrivateGroupViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val exchange = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) + private val blobStorage = mockk(relaxed = true) + private val groupKeys = mockk() + + @Before + fun setUp() { + BottomBarManager.clear() + every { exchange.observePreferredRate() } returns emptyFlow() + every { tokenCoordinator.tokenBalances } returns flowOf(emptyList()) + every { blobStorage.policy } returns flowOf(null) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun TestDispatchers.viewModel() = CreateGroupViewModel( + isPrivate = true, + exchange = exchange, + tokenCoordinator = tokenCoordinator, + dispatchers = this, + chatCoordinator = chatCoordinator, + blobStorage = blobStorage, + imagePreparer = mockk(relaxed = true), + contentReader = mockk(relaxed = true), + resources = FakeResourceHelper(R::class.java), + analytics = RecordingAnalytics(), + groupKeys = groupKeys, + ) + + private val chat = ChatMetadata( + chatId = ChatId(ByteArray(16) { 3 }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = "Ballers", + latestEventSequence = 0, + ) + + @Test + fun `a name alone enables Create`() = runTest(mainCoroutineRule.dispatcher) { + val vm = TestDispatchers(testScheduler).viewModel() + assertFalse(vm.stateFlow.value.canCreate) + + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + + assertTrue(vm.stateFlow.value.canCreate) + assertNull(vm.stateFlow.value.rules) + } + + @Test + fun `create starts a private group then stores the key`() = runTest(mainCoroutineRule.dispatcher) { + val vm = TestDispatchers(testScheduler).viewModel() + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + val parameters = mutableListOf() + coEvery { chatCoordinator.create(capture(parameters), any()) } returns Result.success(chat) + coEvery { groupKeys.storeNewKey(chat.chatId) } returns Result.success(Unit) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceTimeBy(SuccessHoldDuration) + advanceUntilIdle() + + val sent = assertIs(parameters.single()) + assertEquals("Ballers", sent.title) + coVerify(exactly = 1) { groupKeys.storeNewKey(chat.chatId) } + assertEquals(chat, vm.stateFlow.value.created) + } + + @Test + fun `a failed key is retried without starting a second chat`() = runTest(mainCoroutineRule.dispatcher) { + val vm = TestDispatchers(testScheduler).viewModel() + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + coEvery { chatCoordinator.create(any(), any()) } returns Result.success(chat) + coEvery { groupKeys.storeNewKey(chat.chatId) } returns Result.failure(IllegalStateException()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + // The automatic retries ran out: the form is usable again and nothing was created. + coVerify(exactly = 3) { groupKeys.storeNewKey(chat.chatId) } + assertTrue(vm.stateFlow.value.processingState.isIdle) + assertNull(vm.stateFlow.value.created) + + coEvery { groupKeys.storeNewKey(chat.chatId) } returns Result.success(Unit) + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceTimeBy(SuccessHoldDuration) + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.create(any(), any()) } + assertEquals(chat, vm.stateFlow.value.created) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt new file mode 100644 index 0000000000..4ee1a73102 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -0,0 +1,88 @@ +package com.flipcash.services.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.SetKeyEnvelopeError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.opencode.model.core.ID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A private group's chat key, carried to each member as a `chat.v1.KeyEnvelope`. + * + * The creator generates the key once the chat exists ([storeNewKey]) and wraps it for each user it + * admits ([wrapFor]). Wrapping reuses the DM cipher's key agreement under the group-wrap label; the + * server only ever sees envelopes. + */ +@Singleton +class GroupKeyEnvelopes @Inject constructor( + private val cipher: ChatCipher, + private val keys: ChatKeySource, + private val chatController: ChatController, +) { + // Own public key in the key so a different account on this device never reuses a group key. + private data class CacheKey(val chatId: ID, val ownPk: ID) + + private val groupKeys = ConcurrentHashMap() + + /** + * Generates [chatId]'s chat key and stores the creator's own envelope: the second step of + * creating a private group. `ALREADY_SET` is success, since the envelope stored first stands + * and a retry after a lost response lands there. + */ + suspend fun storeNewKey(chatId: ChatId): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val groupKey = cipher.newGroupKey() + val envelope = runCatching { + cipher.wrapGroupKey(own, own.publicKey, chatId.bytes, groupKey).toKeyEnvelope() + }.getOrElse { return Result.failure(it) } + + val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList()) + return chatController.setKeyEnvelope(chatId, envelope) + .onSuccess { groupKeys[cacheKey] = groupKey } + .recoverCatching { error -> + if (error is SetKeyEnvelopeError.AlreadySet) groupKeys.remove(cacheKey) else throw error + } + } + + /** Wraps [chatId]'s chat key for [recipientPk], for the creator to admit them. */ + suspend fun wrapFor(chatId: ChatId, recipientPk: ByteArray): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val groupKey = groupKey(chatId).getOrElse { return Result.failure(it) } + return runCatching { cipher.wrapGroupKey(own, recipientPk, chatId.bytes, groupKey).toKeyEnvelope() } + } + + /** + * The viewer's copy of [chatId]'s chat key, opened from their stored envelope. Only a + * self-wrapped envelope is opened here; one the creator wrapped for a member is the member's + * path, which needs the creator's key. + */ + private suspend fun groupKey(chatId: ChatId): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList()) + groupKeys[cacheKey]?.let { return Result.success(it) } + + val stored = chatController.getKeyEnvelope(chatId).getOrElse { return Result.failure(it) } + return runCatching { + cipher.unwrapGroupKey( + ownKeyPair = own, + wrapperPk = own.publicKey, + chatId = chatId.bytes, + envelope = EncryptedPayload(stored.envelope.nonce, stored.envelope.ciphertext), + ) + }.onSuccess { groupKeys[cacheKey] = it } + } + + private fun EncryptedPayload.toKeyEnvelope() = KeyEnvelope( + scheme = KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305, + nonce = nonce, + ciphertext = ciphertext, + ) +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt new file mode 100644 index 0000000000..454f8b2a56 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -0,0 +1,90 @@ +package com.flipcash.services.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.SetKeyEnvelopeError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.StoredKeyEnvelope +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GroupKeyEnvelopesTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val creatorKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val memberPk = ByteArray(32) { 21 } + + private val keys = object : ChatKeySource { + override fun ownKeyPair() = creatorKeys + override suspend fun peerPublicKey(userId: ID): Result = error("unused") + } + private val controller = mockk() + private val envelopes = GroupKeyEnvelopes(FakeChatCipher, keys, controller) + + @Test + fun `storeNewKey stores a self-wrapped envelope`() = runTest { + val stored = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(stored)) } returns Result.success(Unit) + + assertTrue(envelopes.storeNewKey(chatId).isSuccess) + + assertEquals(KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305, stored.captured.scheme) + val opened = FakeChatCipher.unwrapGroupKey( + creatorKeys, creatorKeys.publicKey, chatId.bytes, + EncryptedPayload(stored.captured.nonce, stored.captured.ciphertext), + ) + assertContentEquals(FakeChatCipher.newGroupKey(), opened) + } + + @Test + fun `ALREADY_SET counts as stored`() = runTest { + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(SetKeyEnvelopeError.AlreadySet()) + + assertTrue(envelopes.storeNewKey(chatId).isSuccess) + } + + @Test + fun `any other failure is returned`() = runTest { + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(IOException("offline")) + + assertTrue(envelopes.storeNewKey(chatId).isFailure) + } + + @Test + fun `wrapFor reuses the key just stored`() = runTest { + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + envelopes.storeNewKey(chatId) + + val envelope = envelopes.wrapFor(chatId, memberPk).getOrThrow() + + val member = KeyPair(publicKey = memberPk, privateKey = ByteArray(64)) + val opened = FakeChatCipher.unwrapGroupKey( + member, creatorKeys.publicKey, chatId.bytes, EncryptedPayload(envelope.nonce, envelope.ciphertext), + ) + assertContentEquals(FakeChatCipher.newGroupKey(), opened) + coVerify(exactly = 0) { controller.getKeyEnvelope(any()) } + } + + @Test + fun `wrapFor opens the stored envelope when nothing is cached`() = runTest { + val self = FakeChatCipher.wrapGroupKey(creatorKeys, creatorKeys.publicKey, chatId.bytes, ByteArray(32) { 5 }) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success( + StoredKeyEnvelope(KeyEnvelope(1, self.nonce, self.ciphertext), wrappedBy = null), + ) + + val envelope = envelopes.wrapFor(chatId, memberPk).getOrThrow() + + assertContentEquals(creatorKeys.publicKey + memberPk + ByteArray(32) { 5 }, envelope.ciphertext) + } +} From d0404c23998f803ecaab531c0023020b6d2bf4dc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 14:06:05 -0400 Subject: [PATCH 02/25] fix(groups): drop "can't be changed later" from the private-group footnote A private group's name and image stay editable after create, so the sentence was wrong. --- apps/flipcash/core/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e59c09095c..7ed5e597ee 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1106,7 +1106,7 @@ Create a Public Group Create a Private Group New Private Group - Only people you approve can join. Messages are end-to-end encrypted. This can\'t be changed later. + Only people you approve can join. Messages are end-to-end encrypted. Find Someone by Username From bce2f4a638216d24f6be2fbe8961790dbc17ff89 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 14:17:08 -0400 Subject: [PATCH 03/25] feat(chat): request to join a private group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private group's profile now shows Request to Join to a non-member, then Request Sent with "Requested · ...once approves" and Cancel Request (EnterLobby / LeaveLobby). LOBBY_FULL and TOO_MANY_LOBBIES get their own sheets; ALREADY_MEMBER refreshes the feed. Requested chat ids are stored per account and dropped once GetChat reports in_lobby false, checked when the profile opens and when the chat list loads. A private group a non-member opens by link or QR swaps to its profile. chat_metadata gains is_private (schema 45). The upsert ORs it in, since a group never stops being private and a partial row must not reset it. --- .../core/src/main/res/values/strings.xml | 18 + .../app/messenger/internal/ChatViewModel.kt | 128 +- .../internal/screens/MessengerScreen.kt | 11 + .../screens/profile/GroupProfileScreen.kt | 142 ++- .../screens/profile/GroupProfileState.kt | 45 + .../messenger/internal/ChatEventStallTest.kt | 1 + .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatGroupCashLinkTest.kt | 1 + .../internal/ChatMentionPickerTest.kt | 1 + .../internal/ChatOpenTranscriptTest.kt | 1 + .../internal/ChatSendFailureAnalyticsTest.kt | 1 + .../messenger/internal/ChatTypingGateTest.kt | 1 + .../screens/profile/PrivateGroupJoinTest.kt | 54 + .../app/tipping/internal/ChatsViewModel.kt | 6 + .../internal/ChatsViewModelArchiveTest.kt | 1 + apps/flipcash/shared/chat/build.gradle.kts | 1 + .../com/flipcash/shared/chat/LobbyRequests.kt | 84 ++ .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../internal/DataStoreLobbyRequestStore.kt | 72 ++ .../flipcash/shared/chat/LobbyRequestsTest.kt | 138 +++ .../45.json | 1055 +++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../app/persistence/dao/ChatMetadataDao.kt | 6 +- .../entities/ChatMetadataEntity.kt | 4 + .../persistence/dao/ChatMetadataDaoTest.kt | 20 + .../sources/mapper/chat/ChatEntityMapper.kt | 2 + .../mapper/chat/ChatEntityMapperTest.kt | 9 + 27 files changed, 1807 insertions(+), 6 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreLobbyRequestStore.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LobbyRequestsTest.kt create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/45.json diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 7ed5e597ee..7bc47917d8 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1106,6 +1106,24 @@ Create a Public Group Create a Private Group New Private Group + Request to Join + Request Sent + Cancel Request + Private group · %1$s + The host approves who joins + Requested just now + Requested %1$dm ago + Requested %1$dh ago + Requested %1$dd ago + %1$s · You\'ll be able to open the chat once %2$s approves. + %1$s · You\'ll be able to open the chat once the host approves. + %1$s isn\'t taking requests right now + Too many people are waiting for the host to respond. Try again later. + You have too many pending requests. Cancel one to request another. + Couldn\'t Send Request + Something went wrong. Please try again. + Couldn\'t Cancel Request + Something went wrong. Please try again. Only people you approve can join. Messages are end-to-end encrypted. Find Someone by Username diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index f93a7870c7..78340f3bd4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -62,10 +62,12 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.models.EnterLobbyError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.chat.SampledChatter import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequirements import com.flipcash.app.messenger.internal.screens.profile.GroupProfileStanding +import com.flipcash.app.messenger.internal.screens.profile.LobbyCall import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile @@ -92,6 +94,7 @@ import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.FeaturedGroupsStore +import com.flipcash.shared.chat.LobbyRequests import com.flipcash.shared.chat.media.ChatMediaUploadState import com.flipcash.shared.chat.media.ChatMediaUploads import com.flipcash.shared.chat.media.MediaSendProgress @@ -170,6 +173,7 @@ import kotlin.math.min import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant import java.util.Optional import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope @@ -258,6 +262,7 @@ internal class ChatViewModel @AssistedInject constructor( private val userProfileDataSource: UserProfileDataSource, private val rosterSearch: RosterSearchSource, private val featuredGroups: FeaturedGroupsStore, + private val lobbyRequests: LobbyRequests, private val dispatchers: DispatcherProvider, // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, @@ -460,6 +465,19 @@ internal class ChatViewModel @AssistedInject constructor( val joinProgress: LoadingSuccessState = LoadingSuccessState(), /** A leave is out, so the profile's Leave Chat shows a spinner and cannot be tapped again. */ val leaving: Boolean = false, + /** + * When this account asked to join this private group, while the request is waiting on the + * host. Null when there is no request, or none this device knows of. + */ + val lobbyRequestedAt: Instant? = null, + /** + * The lobby call that is out, so only its own button spins. Kept apart because the + * Requested bar appears before a request settles, and a Cancel button first drawn as a + * spinner keeps the spinner's size. + */ + val lobbyCall: LobbyCall? = null, + /** A private group's host, by name, for "once approves"; null until known. */ + val hostName: String? = null, /** * What the group's profile pins and states about the viewer's balance, decided with the * live balance and rates. Null until they arrive, and for anything that is not a group. @@ -734,6 +752,20 @@ internal class ChatViewModel @AssistedInject constructor( /** The group's profile came on screen: sample its chatters and fill in a missing cover. */ data object GroupProfileOpened : Event + + /** A private group's Request to Join: EnterLobby. */ + data object RequestToJoin : Event + + /** A private group's Cancel Request: LeaveLobby. */ + data object CancelJoinRequest : Event + + /** A lobby call went out ([busy]) or came back. */ + data class LobbyCallChanged(val call: LobbyCall?) : Event + + /** This chat's waiting request, from [LobbyRequests]; null when there is none. */ + data class OnLobbyRequestResolved(val requestedAt: Instant?) : Event + + data class OnHostNameResolved(val name: String?) : Event data class OnChattersLoaded(val chatters: List) : Event data class OnProfileStandingResolved(val standing: GroupProfileStanding?) : Event data class OnRuleTokensResolved(val tokens: Map, RuleCurrency>) : Event @@ -1604,6 +1636,30 @@ internal class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + /** + * EnterLobby's refusals that the viewer can act on get the leave dialog's sheet: a full lobby + * and too many pending requests. Already a member says nothing; the synced feed flips the + * profile to the member layout. Anything else is a plain failure. + */ + private fun showLobbyError(group: ChatSubject.Group, error: Throwable) { + val message = when (error) { + is EnterLobbyError.LobbyFull -> R.string.prompt_description_lobbyFull + is EnterLobbyError.TooManyLobbies -> R.string.prompt_description_tooManyLobbies + is EnterLobbyError.AlreadyMember -> return + else -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToRequestJoin), + message = resources.getString(R.string.error_description_failedToRequestJoin), + ) + return + } + } + BottomBarManager.showInfo( + title = resources.getString(R.string.prompt_title_lobbyClosed, group.title), + message = resources.getString(message), + ) + } + /** * Replies to the voucher with a thank-you, once a link tapped here has actually been collected. * @@ -2488,6 +2544,72 @@ internal class ChatViewModel @AssistedInject constructor( } .launchIn(viewModelScope) + // A private group's request, as LobbyRequests keeps it. Observed so a cancel elsewhere, or a + // refresh that finds the request answered, puts Request to Join back. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> lobbyRequests.observe().map { it[chatId] } } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnLobbyRequestResolved(it)) } + .launchIn(viewModelScope) + + // A private group's request line names its host; only the creator's id travels with the + // metadata, so the name comes from the profiles the transcript also reads. + stateFlow.map { (it.subject as? ChatSubject.Group)?.takeIf { group -> group.isPrivate }?.creator } + .distinctUntilChanged() + .flatMapLatest { creator -> + if (creator == null) { + flowOf(null) + } else { + chatCoordinator.requestSenderProfile(creator) + chatCoordinator.observeSenderProfiles().map { profiles -> + profiles[creator.hexEncodedString()]?.let { profile -> + profile.displayName.takeIf { it.isNotBlank() } ?: profile.username + } + } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnHostNameResolved(it)) } + .launchIn(viewModelScope) + + // No push says a request was answered, so the profile asks each time it comes up. + eventFlow.filterIsInstance() + .onEach { + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + if (group.isPrivate && group.isMember != true) lobbyRequests.refresh(listOf(group.chatId)) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + if (stateFlow.value.lobbyCall != null) return@onEach + dispatchEvent(Event.LobbyCallChanged(LobbyCall.Request)) + lobbyRequests.request(group.chatId).onFailure { error -> + trace("failed to enter lobby - ${error.localizedMessage}") + showLobbyError(group, error) + } + dispatchEvent(Event.LobbyCallChanged(null)) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + if (stateFlow.value.lobbyCall != null) return@onEach + dispatchEvent(Event.LobbyCallChanged(LobbyCall.Cancel)) + lobbyRequests.cancel(chatId).onFailure { + trace("failed to leave lobby - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToCancelJoinRequest), + message = resources.getString(R.string.error_description_failedToCancelJoinRequest), + ) + } + dispatchEvent(Event.LobbyCallChanged(null)) + } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() .onEach { val chatId = stateFlow.value.chatId ?: return@onEach @@ -3153,7 +3275,11 @@ internal class ChatViewModel @AssistedInject constructor( is Event.GateFundingTapped, Event.GroupInfoOpened, Event.InviteCardFollowed, - Event.LeaveChat, Event.GroupProfileOpened -> { state -> state } + Event.LeaveChat, Event.GroupProfileOpened, + Event.RequestToJoin, Event.CancelJoinRequest -> { state -> state } + is Event.LobbyCallChanged -> { state -> state.copy(lobbyCall = event.call) } + is Event.OnLobbyRequestResolved -> { state -> state.copy(lobbyRequestedAt = event.requestedAt) } + is Event.OnHostNameResolved -> { state -> state.copy(hostName = event.name) } is Event.OnChattersLoaded -> { state -> state.copy(chatters = event.chatters) } is Event.OnProfileStandingResolved -> { state -> state.copy(profileStanding = event.standing) } is Event.OnRuleTokensResolved -> { state -> state.copy(ruleTokens = event.tokens) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 98ac8dfee0..f4eba09467 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens +import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -39,6 +40,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep +import com.getcode.navigation.flow.rememberFlowNavigator import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.links.ExternalLinkUriHandler import com.flipcash.app.core.links.LocalTrustedWebsites @@ -104,6 +106,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val photoSources = remember { ChatPhotoSources() } var openPhotoId by remember { mutableStateOf(null) } + // A private group the viewer is not in has no transcript to show: no key envelope, so nothing + // to decrypt. A share link or deep link only knows the chat id and lands here first, so once the + // group resolves as private, swap to its profile, where the viewer can ask in. + val flowNavigator = rememberFlowNavigator() + val privateOutsider = (state.subject as? ChatSubject.Group)?.let { it.isPrivate && it.isMember != true } == true + LaunchedEffect(privateOutsider) { + if (privateOutsider) flowNavigator.replaceStack(listOf(ChatStep.GroupProfile)) + } + // Here rather than in the action handler because the lookup comes first: the tap only knows a // handle, and where it leads is the view model's to decide once the handle has an answer. LaunchedEffect(viewModel) { 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 54251d24dd..7c9cc6d9b1 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 @@ -8,6 +8,15 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material.icons.filled.Lock +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.Dp +import dev.chrisbanes.haze.HazeState +import androidx.compose.ui.text.style.TextAlign +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Clock +import androidx.compose.runtime.produceState +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -106,6 +115,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group val isMember = group?.isMember == true + // 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( + isPrivate = group?.isPrivate == true, + isMember = group?.isMember, + requestedAt = state.lobbyRequestedAt, + ) // Each time the screen is entered, not once per flow: the view model outlives this screen, and // who is chatting moves between visits. @@ -158,7 +174,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { var pinnedHeight by remember { mutableStateOf(0.dp) } // Opened from the chat, back already returns to it, so nothing is pinned: Leave Chat sits at // the foot of the page instead. - val clearance = if (openedFromChat) 0.dp else pinnedHeight + val clearance = if (openedFromChat && privateJoin == null) 0.dp else pinnedHeight val hazeState = rememberHazeState() CodeScaffold { padding -> @@ -227,6 +243,15 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { }, ) + if (group?.isPrivate == true) { + PrivateGroupNote( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x3), + memberCount = group.memberCount, + ) + } + val unknownName = stringResource(R.string.title_unnamedUser) val chatters = remember(state.chatters, unknownName) { state.chatters.mapNotNull { chatter -> @@ -268,7 +293,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { } AnimatedVisibility( - visible = requirements != null && namesReady, + visible = requirements != null && namesReady && group?.isPrivate != true, enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), ) { @@ -378,7 +403,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // Open Chat in every standing, as in the design: a viewer short of a minimum is told // so, and buys, on the chat's own gate. - if (!openedFromChat) { + if (privateJoin != null) { + PrivateJoinBar( + modifier = Modifier.align(Alignment.BottomCenter), + join = privateJoin, + hostName = state.hostName, + lobbyCall = state.lobbyCall, + onRequest = { viewModel.dispatchEvent(ChatViewModel.Event.RequestToJoin) }, + onCancel = { viewModel.dispatchEvent(ChatViewModel.Event.CancelJoinRequest) }, + hazeState = hazeState, + onHeightChanged = { pinnedHeight = it }, + ) + } else if (!openedFromChat) { ProfilePinnedActionBar( modifier = Modifier.align(Alignment.BottomCenter), text = stringResource(R.string.action_openChat), @@ -435,3 +471,103 @@ private fun GroupProfileMenu( /** How long the requirement amounts wait for their token's name before showing without it. */ private val RuleNameHold = 1500.milliseconds + +/** "Private group · 48 members" over "The host approves who joins", behind a lock. */ +@Composable +private fun PrivateGroupNote(memberCount: Long, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(14.dp), + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + Text( + text = stringResource( + R.string.subtitle_privateGroupMembers, + pluralStringResource(R.plurals.subtitle_memberCount, memberCount.toInt(), memberCount.toString()), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Text( + text = stringResource(R.string.subtitle_hostApprovesJoins), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } +} + +/** + * A private group's pinned action for a non-member: Request to Join, or a disabled Request Sent + * with Cancel Request under it and how long the request has waited above it. + */ +@Composable +private fun PrivateJoinBar( + join: PrivateGroupJoin, + hostName: String?, + lobbyCall: LobbyCall?, + onRequest: () -> Unit, + onCancel: () -> Unit, + hazeState: HazeState, + onHeightChanged: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + when (join) { + PrivateGroupJoin.Request -> ProfilePinnedActionBar( + modifier = modifier, + text = stringResource(R.string.action_requestToJoin), + onClick = onRequest, + isLoading = lobbyCall == LobbyCall.Request, + enabled = lobbyCall == null, + hazeState = hazeState, + onHeightChanged = onHeightChanged, + ) + is PrivateGroupJoin.Requested -> { + // Ticks so "2m ago" does not stand still while the screen stays up. + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + val age = when (val age = RequestAge.of(join.at, now)) { + RequestAge.JustNow -> stringResource(R.string.subtitle_requestedJustNow) + is RequestAge.Minutes -> stringResource(R.string.subtitle_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.subtitle_requestedHoursAgo, age.count) + is RequestAge.Days -> stringResource(R.string.subtitle_requestedDaysAgo, age.count) + } + ProfilePinnedActionBar( + modifier = modifier, + text = stringResource(R.string.action_requestSent), + onClick = {}, + enabled = false, + above = { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + text = if (hostName != null) { + stringResource(R.string.subtitle_requestPendingHost, age, hostName) + } else { + stringResource(R.string.subtitle_requestPendingHostUnnamed, age) + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + }, + secondaryText = stringResource(R.string.action_cancelRequest), + onSecondaryClick = onCancel, + isSecondaryLoading = lobbyCall == LobbyCall.Cancel, + hazeState = hazeState, + onHeightChanged = onHeightChanged, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt index e406b275b4..3878b73ed5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt @@ -16,6 +16,11 @@ import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.solana.keys.Mint import java.math.BigDecimal import java.math.RoundingMode +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant /** * The one action pinned to the bottom of a group's profile. @@ -234,3 +239,43 @@ internal fun gateFunding( } private const val MICRO_SCALE = 6 + +/** + * What a private group's profile offers a viewer who is not in it. Null for a public group and for + * a member, who get the usual layout. + */ +internal sealed interface PrivateGroupJoin { + data object Request : PrivateGroupJoin + data class Requested(val at: Instant) : PrivateGroupJoin + + companion object { + fun resolve(isPrivate: Boolean, isMember: Boolean?, requestedAt: Instant?): PrivateGroupJoin? = when { + !isPrivate || isMember == true -> null + requestedAt != null -> Requested(requestedAt) + else -> Request + } + } +} + +/** How long ago a join request went in, as "Requested 2h ago" states it. */ +internal sealed interface RequestAge { + data object JustNow : RequestAge + data class Minutes(val count: Long) : RequestAge + data class Hours(val count: Long) : RequestAge + data class Days(val count: Long) : RequestAge + + companion object { + fun of(at: Instant, now: Instant): RequestAge { + val elapsed = (now - at).coerceAtLeast(Duration.ZERO) + return when { + elapsed < 1.minutes -> JustNow + elapsed < 1.hours -> Minutes(elapsed.inWholeMinutes) + elapsed < 1.days -> Hours(elapsed.inWholeHours) + else -> Days(elapsed.inWholeDays) + } + } + } +} + +/** A lobby call in flight from a private group's profile. */ +enum class LobbyCall { Request, Cancel } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index 2a47a8cb38..e9340167ba 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -157,6 +157,7 @@ class ChatEventStallTest { userProfileDataSource = userProfileDataSource, rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index c31c9fe650..f3c9cafc62 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -166,6 +166,7 @@ class ChatGroupAnalyticsTest { userProfileDataSource = userProfileDataSource, rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index adf5f45d5f..9fd059a952 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -170,6 +170,7 @@ class ChatGroupCashLinkTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index 63914757da..0b8bf0d76b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -114,6 +114,7 @@ class ChatMentionPickerTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = rosterSearch, featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 5087bbce1f..d17cc2cbe3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -119,6 +119,7 @@ class ChatOpenTranscriptTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = featuredGroups, + lobbyRequests = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = identifier, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 0c2d4ebea1..86096499e6 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -157,6 +157,7 @@ class ChatSendFailureAnalyticsTest { userProfileDataSource = userProfileDataSource, rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt index e8c512ffab..05b31400ba 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -118,6 +118,7 @@ class ChatTypingGateTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt new file mode 100644 index 0000000000..3dd42308fd --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt @@ -0,0 +1,54 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class PrivateGroupJoinTest { + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @Test + fun `public group has no join action`() { + assertNull(PrivateGroupJoin.resolve(isPrivate = false, isMember = false, requestedAt = null)) + } + + @Test + fun `member of a private group has no join action`() { + assertNull(PrivateGroupJoin.resolve(isPrivate = true, isMember = true, requestedAt = at)) + } + + @Test + fun `non-member without a request can ask`() { + assertEquals(PrivateGroupJoin.Request, PrivateGroupJoin.resolve(isPrivate = true, isMember = false, requestedAt = null)) + } + + @Test + fun `unresolved membership is treated as not a member`() { + assertEquals(PrivateGroupJoin.Request, PrivateGroupJoin.resolve(isPrivate = true, isMember = null, requestedAt = null)) + } + + @Test + fun `non-member with a request is waiting`() { + assertEquals(PrivateGroupJoin.Requested(at), PrivateGroupJoin.resolve(isPrivate = true, isMember = false, requestedAt = at)) + } + + @Test + fun `request age buckets`() { + assertEquals(RequestAge.JustNow, RequestAge.of(at, at + 59.seconds)) + assertEquals(RequestAge.Minutes(1), RequestAge.of(at, at + 1.minutes)) + assertEquals(RequestAge.Minutes(59), RequestAge.of(at, at + 59.minutes + 59.seconds)) + assertEquals(RequestAge.Hours(2), RequestAge.of(at, at + 2.hours + 30.minutes)) + assertEquals(RequestAge.Days(3), RequestAge.of(at, at + 3.days + 5.hours)) + } + + @Test + fun `a clock behind the server reads as just now`() { + assertEquals(RequestAge.JustNow, RequestAge.of(at, at - 5.minutes)) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 45bfe79fcb..db8eb72e1c 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -10,6 +10,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.LobbyRequests import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatListEntry @@ -38,6 +39,7 @@ internal class ChatsViewModel @Inject constructor( private val resources: ResourceHelper, dispatchers: DispatcherProvider, private val archiveStore: ChatArchiveStore, + private val lobbyRequests: LobbyRequests, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -100,6 +102,10 @@ internal class ChatsViewModel @Inject constructor( } init { + // No push says a join request was answered; an admitted group only shows up once the feed + // is synced, which refresh() does when GetChat says a request has left its lobby. + viewModelScope.launch { lobbyRequests.refresh() } + val selfId = { userManager.accountId } fun content( diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index aba5953887..a76670544b 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -72,6 +72,7 @@ class ChatsViewModelArchiveTest { resources = FakeResourceHelper(R::class.java), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, + lobbyRequests = mockk(relaxed = true), ) val states = mutableListOf() diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 3764ae2786..fd45bb87cc 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(libs.bundles.kotlinx.serialization) implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.datastore) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:persistence:sources")) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt new file mode 100644 index 0000000000..639620c347 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt @@ -0,0 +1,84 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.EnterLobbyError +import com.flipcash.services.models.LeaveLobbyError +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +/** + * The private groups this account has asked to join and not yet seen an answer for, keyed by chat + * with the time the request went in. + * + * The contract has no "my requests" list and no push for an outcome: an admission or a denial only + * shows as `GetChat.in_lobby` going false. So the requests are kept on the device, and [refresh] + * asks GetChat about each one. A request that has left the lobby is dropped; if any did, the feed + * is synced, since an admitted chat arrives there and its row is what flips the viewer to member. + */ +@Singleton +class LobbyRequests @Inject constructor( + private val chatController: ChatController, + private val store: LobbyRequestStore, + private val chatCoordinator: ChatCoordinator, +) { + + /** Requests sent and not yet answered, re-emitted on every change. */ + fun observe(): Flow> = store.observe() + + /** + * EnterLobby. On success the request is kept with the server's `entered_at`. ALREADY_MEMBER + * drops any stale request and syncs the feed so the member's row arrives; it still fails, so + * the caller can tell it apart. Other failures change nothing. + */ + suspend fun request(chatId: ChatId): Result = + chatController.enterLobby(chatId) + .onSuccess { store.put(chatId, it.enteredAt) } + .onFailure { error -> + if (error is EnterLobbyError.AlreadyMember) { + store.remove(chatId) + chatCoordinator.refreshFeed() + } + } + .map { it.enteredAt } + + /** + * LeaveLobby. NOT_FOUND means the request is already gone (denied, or admitted and since + * left), so it also counts as cancelled. + */ + suspend fun cancel(chatId: ChatId): Result = + chatController.leaveLobby(chatId) + .recoverCatching { if (it !is LeaveLobbyError.NotFound) throw it } + .onSuccess { store.remove(chatId) } + + /** + * Asks GetChat about [chatIds], or every stored request when null, and drops each that is no + * longer in the lobby. A failed GetChat keeps its request: an unanswered question is not a no. + */ + suspend fun refresh(chatIds: Collection? = null) { + val stored = store.observe().first() + val asking = chatIds?.filter { it in stored } ?: stored.keys + var resolved = false + asking.forEach { chatId -> + val metadata = chatController.getChat(chatId).getOrNull() ?: return@forEach + if (!metadata.inLobby) { + store.remove(chatId) + resolved = true + } + } + if (resolved) chatCoordinator.refreshFeed() + } +} + +/** Where [LobbyRequests] keeps its requests, per signed-in account. */ +interface LobbyRequestStore { + /** The current account's requests; empty before sign-in. */ + fun observe(): Flow> + + suspend fun put(chatId: ChatId, enteredAt: Instant) + + suspend fun remove(chatId: ChatId) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index d3d1c3c6b7..b0a6f13745 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -3,7 +3,9 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.LobbyRequestStore import com.flipcash.shared.chat.RosterSearchSource +import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.flipcash.shared.chat.internal.MentionSuggestionPool @@ -44,6 +46,11 @@ abstract class ChatModule { impl: RealChatArchiveStore ): ChatArchiveStore + @Binds + internal abstract fun bindLobbyRequestStore( + impl: DataStoreLobbyRequestStore + ): LobbyRequestStore + @Binds @Singleton abstract fun bindChatMediaSending( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreLobbyRequestStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreLobbyRequestStore.kt new file mode 100644 index 0000000000..6978563b09 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreLobbyRequestStore.kt @@ -0,0 +1,72 @@ +package com.flipcash.shared.chat.internal + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.LobbyRequestStore +import com.getcode.utils.hexEncodedString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +private val Context.lobbyRequestsDataStore: DataStore by preferencesDataStore(name = "lobby_requests") + +/** + * One string set per account, each entry `:`. Per account + * because one device serves several, and a request belongs to the account that sent it. + */ +@Singleton +internal class DataStoreLobbyRequestStore @Inject constructor( + @ApplicationContext context: Context, + private val userManager: UserManager, +) : LobbyRequestStore { + + private val dataStore = context.lobbyRequestsDataStore + + override fun observe(): Flow> = + combine( + userManager.state.map { it.accountId?.hexEncodedString() }.distinctUntilChanged(), + dataStore.data, + ) { owner, prefs -> + owner ?: return@combine emptyMap() + prefs[key(owner)].orEmpty().mapNotNull(::decode).toMap() + }.distinctUntilChanged() + + override suspend fun put(chatId: ChatId, enteredAt: Instant) = edit { entries -> + entries.filterKeys { it != chatId } + (chatId to enteredAt) + } + + override suspend fun remove(chatId: ChatId) = edit { entries -> entries - chatId } + + private suspend fun edit(change: (Map) -> Map) { + val owner = userManager.accountId?.hexEncodedString() ?: return + dataStore.edit { prefs -> + val key = key(owner) + val current = prefs[key].orEmpty().mapNotNull(::decode).toMap() + prefs[key] = change(current).map { (chatId, at) -> encode(chatId, at) }.toSet() + } + } + + private fun key(owner: String) = stringSetPreferencesKey("requests-$owner") + + @OptIn(ExperimentalStdlibApi::class) + private fun encode(chatId: ChatId, enteredAt: Instant) = "${chatId.bytes.toHexString()}:${enteredAt.epochSeconds}" + + @OptIn(ExperimentalStdlibApi::class) + private fun decode(raw: String): Pair? { + val separator = raw.indexOf(':').takeIf { it > 0 } ?: return null + val bytes = runCatching { raw.substring(0, separator).hexToByteArray() }.getOrNull() ?: return null + val seconds = raw.substring(separator + 1).toLongOrNull() ?: return null + return ChatId(bytes) to Instant.fromEpochSeconds(seconds) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LobbyRequestsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LobbyRequestsTest.kt new file mode 100644 index 0000000000..03db2cd3be --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LobbyRequestsTest.kt @@ -0,0 +1,138 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.EnterLobbyError +import com.flipcash.services.models.LeaveLobbyError +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.Lobby +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +class LobbyRequestsTest { + + private class MemoryStore : LobbyRequestStore { + val entries = MutableStateFlow>(emptyMap()) + override fun observe(): Flow> = entries + override suspend fun put(chatId: ChatId, enteredAt: Instant) { entries.value += chatId to enteredAt } + override suspend fun remove(chatId: ChatId) { entries.value -= chatId } + } + + private val chatController = mockk() + private val chatCoordinator = mockk(relaxed = true) + private val store = MemoryStore() + private val requests = LobbyRequests(chatController, store, chatCoordinator) + + private val aa = ChatId("aa") + private val bb = ChatId("bb") + private val enteredAt = Instant.fromEpochSeconds(1_000) + + private fun group(chatId: ChatId, inLobby: Boolean) = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(0), + title = "group", + isPrivate = true, + inLobby = inLobby, + ) + + @Test + fun `a request that goes in is kept with the server's entered_at`() = runTest { + coEvery { chatController.enterLobby(aa) } returns Result.success(Lobby(group(aa, true), enteredAt)) + + assertEquals(enteredAt, requests.request(aa).getOrThrow()) + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + } + + @Test + fun `a full lobby keeps nothing`() = runTest { + coEvery { chatController.enterLobby(aa) } returns Result.failure(EnterLobbyError.LobbyFull()) + + assertIs(requests.request(aa).exceptionOrNull()) + + assertEquals(emptyMap(), requests.observe().first()) + verify(exactly = 0) { chatCoordinator.refreshFeed() } + } + + @Test + fun `already a member drops the request and syncs the feed`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.enterLobby(aa) } returns Result.failure(EnterLobbyError.AlreadyMember()) + + assertIs(requests.request(aa).exceptionOrNull()) + + assertEquals(emptyMap(), requests.observe().first()) + verify { chatCoordinator.refreshFeed() } + } + + @Test + fun `cancel drops the request, and not found counts as cancelled`() = runTest { + store.put(aa, enteredAt) + store.put(bb, enteredAt) + coEvery { chatController.leaveLobby(aa) } returns Result.success(Unit) + coEvery { chatController.leaveLobby(bb) } returns Result.failure(LeaveLobbyError.NotFound()) + + assertTrue(requests.cancel(aa).isSuccess) + assertTrue(requests.cancel(bb).isSuccess) + + assertEquals(emptyMap(), requests.observe().first()) + } + + @Test + fun `a failed cancel keeps the request`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.leaveLobby(aa) } returns Result.failure(LeaveLobbyError.Other()) + + assertTrue(requests.cancel(aa).isFailure) + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + } + + @Test + fun `refresh drops requests no longer in the lobby and syncs the feed`() = runTest { + store.put(aa, enteredAt) + store.put(bb, enteredAt) + coEvery { chatController.getChat(aa, any()) } returns Result.success(group(aa, inLobby = false)) + coEvery { chatController.getChat(bb, any()) } returns Result.success(group(bb, inLobby = true)) + + requests.refresh() + + assertEquals(mapOf(bb to enteredAt), requests.observe().first()) + verify(exactly = 1) { chatCoordinator.refreshFeed() } + } + + @Test + fun `refresh keeps a request GetChat could not answer for`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.getChat(aa, any()) } returns Result.failure(Throwable("offline")) + + requests.refresh() + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + verify(exactly = 0) { chatCoordinator.refreshFeed() } + } + + @Test + fun `refresh of one chat asks only about that chat, and only if it was requested`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.getChat(aa, any()) } returns Result.success(group(aa, inLobby = true)) + + requests.refresh(listOf(aa, bb)) + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/45.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/45.json new file mode 100644 index 0000000000..741745ffcb --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/45.json @@ -0,0 +1,1055 @@ +{ + "formatVersion": 1, + "database": { + "version": 45, + "identityHash": "3547b6f22b17c4e3391862d768a46e41", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3547b6f22b17c4e3391862d768a46e41')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index bff0da7a7c..309f74ac3d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -125,8 +125,9 @@ import com.getcode.utils.subByteArray // dropped by the destructive fallback is a photo the viewer queued and never sent. AutoMigration(from = 42, to = 43), // pending_media table AutoMigration(from = 43, to = 44), // chat_metadata.cover_picture_json (nullable) + AutoMigration(from = 44, to = 45), // chat_metadata.is_private (default 0) ], - version = 44, + version = 45, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 0b383376ed..6f102728c1 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -94,7 +94,9 @@ interface ChatMetadataDao { "cover_picture_json = COALESCE(:coverPictureJson, cover_picture_json), " + "rules_json = :rulesJson, " + "is_member = :isMember, " + - "use_e2ee = :useE2ee " + + "use_e2ee = :useE2ee, " + + // Never cleared: privacy is fixed at creation, so a row rebuilt without it keeps it. + "is_private = (is_private OR :isPrivate) " + "WHERE chat_id_hex = :chatIdHex" ) suspend fun updateServerOwnedFields( @@ -110,6 +112,7 @@ interface ChatMetadataDao { rulesJson: ChatRulesSerialized?, isMember: Boolean, useE2ee: Boolean, + isPrivate: Boolean, ) /** @@ -212,6 +215,7 @@ interface ChatMetadataDao { rulesJson = entity.rulesJson, isMember = entity.isMember, useE2ee = entity.useE2ee, + isPrivate = entity.isPrivate, ) updateRosterIfNewer( chatIdHex = entity.chatIdHex, diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index 5726c30d09..882e208ef7 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -73,6 +73,10 @@ data class ChatMetadataEntity( // behaviourally for now; carried so a chat rebuilt from Room agrees with the network value. @ColumnInfo(name = "use_e2ee", defaultValue = "0") val useE2ee: Boolean = false, + // A private group: the host admits each member and messages are end-to-end encrypted. Set at + // creation and never changed, so the upsert only ever turns it on. + @ColumnInfo(name = "is_private", defaultValue = "0") + val isPrivate: Boolean = false, // Group description, up to 160 characters; null when unset or on a DM. @ColumnInfo(name = "description") val description: String? = null, diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 628a752b70..b4b8e38944 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -65,6 +65,7 @@ class ChatMetadataDaoTest { viewerStateVersion: Long = 0, canEdit: Boolean = false, useE2ee: Boolean = false, + isPrivate: Boolean = false, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -86,6 +87,7 @@ class ChatMetadataDaoTest { viewerStateVersion = viewerStateVersion, canEdit = canEdit, useE2ee = useE2ee, + isPrivate = isPrivate, ) @Test @@ -187,6 +189,24 @@ class ChatMetadataDaoTest { assertEquals(true, dao.getById(CHAT_HEX)?.useE2ee) } + @Test + fun `upsert keeps a private group private when a write does not carry it`() = runTest { + dao.upsert(entity(isPrivate = true)) + + dao.upsert(entity(isPrivate = false)) + + assertEquals(true, dao.getById(CHAT_HEX)?.isPrivate) + } + + @Test + fun `upsert picks up is_private for a stored chat`() = runTest { + dao.upsert(entity(isPrivate = false)) + + dao.upsert(entity(isPrivate = true)) + + assertEquals(true, dao.getById(CHAT_HEX)?.isPrivate) + } + @Test fun `upsert picks up use_e2ee turning off for a stored chat`() = runTest { dao.upsert(entity(useE2ee = true)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index a671ba8df9..fba16d9df8 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -100,6 +100,7 @@ class ChatEntityMapper @Inject constructor() { canEdit = metadata.viewerState?.permissions?.canEdit ?: false, creatorHex = metadata.creator?.hexEncodedString(), useE2ee = metadata.useE2ee, + isPrivate = metadata.isPrivate, ) } @@ -133,6 +134,7 @@ class ChatEntityMapper @Inject constructor() { viewerState = entity.toViewerState(), creator = entity.creatorHex?.hexToIdExt(), useE2ee = entity.useE2ee, + isPrivate = entity.isPrivate, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index c49a4d999d..6accf15a51 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -294,6 +294,15 @@ class ChatEntityMapperTest { assertEquals(true, restored.useE2ee) } + @Test + fun `a private group stays private through the row`() { + val entity = mapper.toEntity(groupMetadata().copy(isPrivate = true)) + assertEquals(true, entity.isPrivate) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + assertEquals(true, restored.isPrivate) + } + @Test fun `group description survives the round trip through the row`() { val entity = mapper.toEntity(groupMetadata().copy(description = "Weekly tips")) From 8a3ae9c46c1d9f10c45e6635862630d12b4c80f2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 14:27:42 -0400 Subject: [PATCH 04/25] fix(chat): show a join request's age on its button The pending button reads "Request Sent" for the first minute, then "Requested 10m ago", "2h ago", "3d ago". The line above it keeps only "You'll be able to open the chat once approves.", so the age is no longer said twice. --- .../core/src/main/res/values/strings.xml | 11 +++++------ .../screens/profile/GroupProfileScreen.kt | 17 +++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 7bc47917d8..9eb741ff45 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1111,12 +1111,11 @@ Cancel Request Private group · %1$s The host approves who joins - Requested just now - Requested %1$dm ago - Requested %1$dh ago - Requested %1$dd ago - %1$s · You\'ll be able to open the chat once %2$s approves. - %1$s · You\'ll be able to open the chat once the host approves. + Requested %1$dm ago + Requested %1$dh ago + Requested %1$dd ago + You\'ll be able to open the chat once %1$s approves. + You\'ll be able to open the chat once the host approves. %1$s isn\'t taking requests right now Too many people are waiting for the host to respond. Try again later. You have too many pending requests. Cancel one to request another. 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 7c9cc6d9b1..4ac0a2a9e2 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 @@ -536,15 +536,16 @@ private fun PrivateJoinBar( value = Clock.System.now() } } - val age = when (val age = RequestAge.of(join.at, now)) { - RequestAge.JustNow -> stringResource(R.string.subtitle_requestedJustNow) - is RequestAge.Minutes -> stringResource(R.string.subtitle_requestedMinutesAgo, age.count) - is RequestAge.Hours -> stringResource(R.string.subtitle_requestedHoursAgo, age.count) - is RequestAge.Days -> stringResource(R.string.subtitle_requestedDaysAgo, age.count) + // "Request Sent" for the first minute, then how long the request has waited. + val label = when (val age = RequestAge.of(join.at, now)) { + RequestAge.JustNow -> stringResource(R.string.action_requestSent) + is RequestAge.Minutes -> stringResource(R.string.action_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.action_requestedHoursAgo, age.count) + is RequestAge.Days -> stringResource(R.string.action_requestedDaysAgo, age.count) } ProfilePinnedActionBar( modifier = modifier, - text = stringResource(R.string.action_requestSent), + text = label, onClick = {}, enabled = false, above = { @@ -553,9 +554,9 @@ private fun PrivateJoinBar( .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.inset), text = if (hostName != null) { - stringResource(R.string.subtitle_requestPendingHost, age, hostName) + stringResource(R.string.subtitle_requestPendingHost, hostName) } else { - stringResource(R.string.subtitle_requestPendingHostUnnamed, age) + stringResource(R.string.subtitle_requestPendingHostUnnamed) }, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, From 0219a31e494ab20efbfca656b2ef944da7024750 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 14:39:20 -0400 Subject: [PATCH 05/25] fix(chat): drop the period from the pending-request caption --- apps/flipcash/core/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 9eb741ff45..fde451b8ce 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1114,8 +1114,8 @@ Requested %1$dm ago Requested %1$dh ago Requested %1$dd ago - You\'ll be able to open the chat once %1$s approves. - You\'ll be able to open the chat once the host approves. + You\'ll be able to open the chat once %1$s approves + You\'ll be able to open the chat once the host approves %1$s isn\'t taking requests right now Too many people are waiting for the host to respond. Try again later. You have too many pending requests. Cancel one to request another. From f6f6c69b7f5fea84c91518643cb56e65f60824b6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 14:48:25 -0400 Subject: [PATCH 06/25] feat(chat): review join requests for a private group you host HostLobbies (shared/chat) holds each hosted group's lobby: it pages GetLobbyMembers, applies LobbyUpdate from the event stream, and on Approve wraps the group key for the requester before AdmitLobbyMember. Deny and a requester who already left both just drop the row. The host sees the lobby in three places, all read from that store: - Join Requests (node 11206:60), with its empty state (node 11362:237): text and Share Group, no icon. - A card on the group profile with a grey count (node 11205:68), hidden at 0. - A row pinned above Archived in the chat list under the All filter (node 11214:67). With several hosted groups waiting it opens the one with the newest request; there is no switcher frame. Edit Group has no Balance Requirements section on Android, so nothing needed hiding there. --- .../kotlin/com/flipcash/app/core/AppRoute.kt | 9 +- .../com/flipcash/app/core/chat/ChatStep.kt | 9 + .../core/src/main/res/values/strings.xml | 24 ++ .../flipcash/app/messenger/ChatFlowScreen.kt | 4 + .../app/messenger/internal/ChatViewModel.kt | 97 ++++++- .../screens/profile/GroupProfileScreen.kt | 73 +++++ .../screens/profile/GroupProfileState.kt | 3 + .../screens/profile/JoinRequestsScreen.kt | 254 ++++++++++++++++++ .../messenger/internal/ChatEventStallTest.kt | 1 + .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatGroupCashLinkTest.kt | 1 + .../internal/ChatMentionPickerTest.kt | 1 + .../internal/ChatOpenTranscriptTest.kt | 1 + .../internal/ChatSendFailureAnalyticsTest.kt | 1 + .../messenger/internal/ChatTypingGateTest.kt | 1 + .../com/flipcash/app/tipping/ChatsScreen.kt | 100 ++++++- .../app/tipping/internal/ChatsViewModel.kt | 56 ++++ .../internal/ChatsViewModelArchiveTest.kt | 1 + .../internal/HostedJoinRequestsTest.kt | 101 +++++++ .../com/flipcash/shared/chat/HostLobbies.kt | 117 ++++++++ .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../chat/internal/RealChatCoordinator.kt | 5 + .../internal/delegates/EventStreamDelegate.kt | 10 + .../flipcash/shared/chat/HostLobbiesTest.kt | 139 ++++++++++ 24 files changed, 1013 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/HostedJoinRequestsTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index f1226817e1..34a8cb5630 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -363,9 +363,16 @@ sealed interface AppRoute : NavKey, Parcelable { // profile is the flow's root, so back returns to wherever the tap came from, and Open // Chat pushes the transcript on top of it. val openOnProfile: Boolean = false, + // Open on a private group's Join Requests, over its profile so back lands there. Set by + // the chat list's join-requests row. + val openOnJoinRequests: Boolean = false, ) : Messaging, FlowRoute { override val initialStack: List - get() = listOf(if (openOnProfile) ChatStep.GroupProfile else ChatStep.Conversation) + get() = when { + openOnJoinRequests -> listOf(ChatStep.GroupProfile, ChatStep.JoinRequests) + openOnProfile -> listOf(ChatStep.GroupProfile) + else -> listOf(ChatStep.Conversation) + } } /** 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 694031433e..4500a7360f 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 @@ -101,6 +101,15 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object EditGroup : ChatStep + /** + * Node 11206:60 — who is waiting to join a private group, for its creator to approve or deny. + * Carries nothing for the same reason [GroupProfile] does not; the lobby itself comes from the + * shared host-lobby store. + */ + @Parcelize + @Serializable + data object JoinRequests : ChatStep + /** The group's title, on the same entry screen shape the profile name uses. */ @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 fde451b8ce..56ef011003 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1123,6 +1123,30 @@ Something went wrong. Please try again. Couldn\'t Cancel Request Something went wrong. Please try again. + Couldn\'t Approve + Something went wrong. Please try again. + Couldn\'t Deny + Something went wrong. Please try again. + Join Requests + + %1$d person is waiting + %1$d people are waiting + + + %1$d person wants to join %2$s + %1$d people want to join %2$s + + requested just now + requested %1$dm ago + requested %1$dh ago + requested yesterday + requested %1$dd ago + Deny + Approve + Approving shares the group key with them from this device. + No requests right now + When someone asks to join %1$s, they\'ll show up here + Share Group Only people you approve can join. Messages are end-to-end encrypted. Find Someone by Username 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 bb5a6a679e..c558622018 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,7 @@ import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupCoverScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupDescriptionScreen +import com.flipcash.app.messenger.internal.screens.profile.JoinRequestsScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen @@ -119,6 +120,9 @@ private fun chatEntryProvider( annotatedEntry { FlowEditGroupScreen() } + annotatedEntry { + JoinRequestsScreen(flowSharedViewModel()) + } annotatedEntry { FlowEditGroupNameScreen() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 78340f3bd4..fbd8f58e4c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -67,11 +67,13 @@ import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.chat.SampledChatter import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequirements import com.flipcash.app.messenger.internal.screens.profile.GroupProfileStanding +import com.flipcash.app.messenger.internal.screens.profile.JoinRequestCall import com.flipcash.app.messenger.internal.screens.profile.LobbyCall import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType @@ -94,6 +96,7 @@ import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.FeaturedGroupsStore +import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.LobbyRequests import com.flipcash.shared.chat.media.ChatMediaUploadState import com.flipcash.shared.chat.media.ChatMediaUploads @@ -263,6 +266,7 @@ internal class ChatViewModel @AssistedInject constructor( private val rosterSearch: RosterSearchSource, private val featuredGroups: FeaturedGroupsStore, private val lobbyRequests: LobbyRequests, + private val hostLobbies: HostLobbies, private val dispatchers: DispatcherProvider, // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, @@ -478,6 +482,12 @@ internal class ChatViewModel @AssistedInject constructor( val lobbyCall: LobbyCall? = null, /** A private group's host, by name, for "once approves"; null until known. */ val hostName: String? = null, + /** The viewer created this private group, so its lobby is theirs to answer. */ + val isLobbyHost: Boolean = false, + /** Who is waiting to join, newest first. Empty unless [isLobbyHost]. */ + val joinRequests: List = emptyList(), + /** Approve or Deny calls out, by the requester's user id, so only that row waits. */ + val joinRequestCalls: Map = emptyMap(), /** * What the group's profile pins and states about the viewer's balance, decided with the * live balance and rates. Null until they arrive, and for anything that is not a group. @@ -766,6 +776,15 @@ internal class ChatViewModel @AssistedInject constructor( data class OnLobbyRequestResolved(val requestedAt: Instant?) : Event data class OnHostNameResolved(val name: String?) : Event + + /** The viewer's host standing and this group's lobby, from [HostLobbies]. */ + data class OnJoinRequestsResolved(val isHost: Boolean, val members: List) : Event + + /** Join Requests came on screen: refetch, since LobbyUpdate is best-effort. */ + data object JoinRequestsOpened : Event + data class ApproveJoinRequest(val member: LobbyMember) : Event + data class DenyJoinRequest(val member: LobbyMember) : Event + data class JoinRequestCallChanged(val userId: ID, val call: JoinRequestCall?) : Event data class OnChattersLoaded(val chatters: List) : Event data class OnProfileStandingResolved(val standing: GroupProfileStanding?) : Event data class OnRuleTokensResolved(val tokens: Map, RuleCurrency>) : Event @@ -2573,6 +2592,72 @@ internal class ChatViewModel @AssistedInject constructor( .onEach { dispatchEvent(Event.OnHostNameResolved(it)) } .launchIn(viewModelScope) + // The host's side of a private group: its lobby, kept by HostLobbies and loaded whenever the + // viewer turns out to be the creator, so the profile card and the in-chat anchor have it. + stateFlow.map { state -> + (state.subject as? ChatSubject.Group) + ?.takeIf { it.isPrivate && it.creator != null && it.creator == userManager.accountId } + ?.chatId + } + .distinctUntilChanged() + .flatMapLatest { chatId -> + if (chatId == null) { + flowOf(Event.OnJoinRequestsResolved(isHost = false, members = emptyList())) + } else { + viewModelScope.launch { hostLobbies.load(chatId) } + hostLobbies.state.map { Event.OnJoinRequestsResolved(isHost = true, members = it[chatId].orEmpty()) } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + if (stateFlow.value.isLobbyHost) hostLobbies.load(chatId) + } + .launchIn(viewModelScope) + + // Each row's call runs on its own, so one slow Approve does not hold up the rest. + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + val userId = event.member.userProfile.userId ?: return@onEach + if (userId in stateFlow.value.joinRequestCalls) return@onEach + dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Approve)) + viewModelScope.launch { + hostLobbies.approve(chatId, event.member).onFailure { + trace("failed to admit lobby member - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToApproveJoinRequest), + message = resources.getString(R.string.error_description_failedToApproveJoinRequest), + ) + } + dispatchEvent(Event.JoinRequestCallChanged(userId, null)) + } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + val userId = event.member.userProfile.userId ?: return@onEach + if (userId in stateFlow.value.joinRequestCalls) return@onEach + dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Deny)) + viewModelScope.launch { + hostLobbies.deny(chatId, userId).onFailure { + trace("failed to deny lobby member - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToDenyJoinRequest), + message = resources.getString(R.string.error_description_failedToDenyJoinRequest), + ) + } + dispatchEvent(Event.JoinRequestCallChanged(userId, null)) + } + } + .launchIn(viewModelScope) + // No push says a request was answered, so the profile asks each time it comes up. eventFlow.filterIsInstance() .onEach { @@ -3276,7 +3361,17 @@ internal class ChatViewModel @AssistedInject constructor( Event.GroupInfoOpened, Event.InviteCardFollowed, Event.LeaveChat, Event.GroupProfileOpened, - Event.RequestToJoin, Event.CancelJoinRequest -> { state -> state } + Event.RequestToJoin, Event.CancelJoinRequest, + Event.JoinRequestsOpened, is Event.ApproveJoinRequest, is Event.DenyJoinRequest -> { state -> state } + is Event.OnJoinRequestsResolved -> { state -> + state.copy(isLobbyHost = event.isHost, joinRequests = event.members) + } + is Event.JoinRequestCallChanged -> { state -> + val calls = state.joinRequestCalls.toMutableMap() + val call = event.call + if (call == null) calls.remove(event.userId) else calls[event.userId] = call + state.copy(joinRequestCalls = calls) + } is Event.LobbyCallChanged -> { state -> state.copy(lobbyCall = event.call) } is Event.OnLobbyRequestResolved -> { state -> state.copy(lobbyRequestedAt = event.requestedAt) } is Event.OnHostNameResolved -> { state -> state.copy(hostName = event.name) } 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 4ac0a2a9e2..e57675b1b8 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 @@ -1,6 +1,14 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable +import com.getcode.theme.White10 +import com.getcode.theme.White05 +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.expandVertically @@ -252,6 +260,23 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { ) } + // The host's way into Join Requests; gone when nobody is waiting. + AnimatedVisibility( + visible = state.isLobbyHost && state.joinRequests.isNotEmpty(), + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + ) { + var shownCount by remember { mutableStateOf(state.joinRequests.size) } + if (state.joinRequests.isNotEmpty()) shownCount = state.joinRequests.size + JoinRequestsCard( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + count = shownCount, + onClick = { flowNavigator.navigateTo(ChatStep.JoinRequests) }, + ) + } + val unknownName = stringResource(R.string.title_unnamedUser) val chatters = remember(state.chatters, unknownName) { state.chatters.mapNotNull { chatter -> @@ -572,3 +597,51 @@ private fun PrivateJoinBar( } } } + +/** Node 11205:68 — the host's Join Requests row: title, "3 people are waiting", grey count, chevron. */ +@Composable +private fun JoinRequestsCard(count: Int, onClick: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(White05) + .clickable(onClick = onClick) + .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.title_joinRequests), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = pluralStringResource(R.plurals.subtitle_joinRequestsWaiting, count, count), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + // Grey rather than the unread badge's colour: a count of people waiting, not unread messages. + Box( + modifier = Modifier + .heightIn(min = 20.dp) + .clip(CircleShape) + .background(White10) + .padding(horizontal = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = count.toString(), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt index 3878b73ed5..0c66447696 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt @@ -279,3 +279,6 @@ internal sealed interface RequestAge { /** A lobby call in flight from a private group's profile. */ enum class LobbyCall { Request, Cancel } + +/** An Approve or Deny out for one row of Join Requests. */ +enum class JoinRequestCall { Approve, Deny } 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 new file mode 100644 index 0000000000..e18d9b7548 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt @@ -0,0 +1,254 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import android.os.Parcelable +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.GroupInviteSheetSource +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.openGroupInvite +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Node 11206:60 — who is waiting to join a private group the viewer created, newest first, each + * with Deny and Approve. Node 11214:67 is the empty state: two lines and Share Group, no icon. + * + * Reads the flow's shared [ChatViewModel], which holds the lobby from HostLobbies, so this list, + * the profile's card and the in-chat anchor all count the same people. + */ +@Composable +internal fun JoinRequestsScreen(viewModel: ChatViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current + val group = state.subject as? ChatSubject.Group + + // LobbyUpdate is best-effort, so the list refetches whenever it comes on screen. + LaunchedEffect(Unit) { viewModel.dispatchEvent(ChatViewModel.Event.JoinRequestsOpened) } + + // One clock for every row's "requested 2h ago". + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_joinRequests), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + ) { innerPadding -> + if (state.joinRequests.isEmpty()) { + NoJoinRequests( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + groupName = group?.groupTitle.orEmpty(), + onShare = { + viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE)) + navigator.openGroupInvite() + }, + ) + } else { + val unknownName = stringResource(R.string.title_unnamedUser) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(vertical = CodeTheme.dimens.grid.x2), + ) { + items(state.joinRequests, key = { it.publicKey.byteArray.toList() }) { member -> + val userId = member.userProfile.userId + JoinRequestRow( + member = member, + name = chatterName(member.userProfile.displayName, member.userProfile.username, unknownName), + age = RequestAge.of(member.enteredAt, now), + call = userId?.let { state.joinRequestCalls[it] }, + onOpen = { + userId ?: return@JoinRequestRow + flowNavigator.navigateTo( + ChatStep.Profile( + contact = ChatParticipant.TipUser(userId, member.userProfile), + origin = ProfileOrigin.GroupMember, + ) + ) + }, + onDeny = { viewModel.dispatchEvent(ChatViewModel.Event.DenyJoinRequest(member)) }, + onApprove = { viewModel.dispatchEvent(ChatViewModel.Event.ApproveJoinRequest(member)) }, + ) + } + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x10), + text = stringResource(R.string.subtitle_joinRequestsFooter), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +private fun JoinRequestRow( + member: LobbyMember, + name: String, + age: RequestAge, + call: JoinRequestCall?, + onOpen: () -> Unit, + onDeny: () -> Unit, + onApprove: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Row( + modifier = Modifier + .weight(1f) + .clickable(onClick = onOpen), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar(userProfile = member.userProfile, modifier = Modifier.size(40.dp).clip(CircleShape)) + Column { + Text( + text = name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = when (age) { + RequestAge.JustNow -> stringResource(R.string.subtitle_requestedJustNow) + is RequestAge.Minutes -> stringResource(R.string.subtitle_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.subtitle_requestedHoursAgo, age.count) + is RequestAge.Days -> if (age.count == 1L) { + stringResource(R.string.subtitle_requestedYesterday) + } else { + stringResource(R.string.subtitle_requestedDaysAgo, age.count) + } + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + // Deny greys out while Approve runs, and Approve stays its own size with the spinner in it. + CodeButton( + text = stringResource(R.string.action_deny), + buttonState = ButtonState.Filled10, + enabled = call == null, + isLoading = call == JoinRequestCall.Deny, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PillPadding, + overrideContentPadding = true, + onClick = onDeny, + ) + CodeButton( + text = stringResource(R.string.action_approve), + buttonState = ButtonState.Filled, + enabled = call == null || call == JoinRequestCall.Approve, + isLoading = call == JoinRequestCall.Approve, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PillPadding, + overrideContentPadding = true, + onClick = onApprove, + ) + } +} + +@Composable +private fun NoJoinRequests(groupName: String, onShare: () -> Unit, modifier: Modifier = Modifier) { + Box(modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_noJoinRequests), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + // The frame wraps this at about two-thirds of the screen width. + modifier = Modifier.widthIn(max = 280.dp), + text = stringResource(R.string.subtitle_noJoinRequests, groupName), + 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_shareGroup), + buttonState = ButtonState.Filled10, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PillPadding, + overrideContentPadding = true, + onClick = onShare, + ) + } + } +} + +private val PillPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index e9340167ba..53a20ad231 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -158,6 +158,7 @@ class ChatEventStallTest { rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index f3c9cafc62..88b1f504d3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -167,6 +167,7 @@ class ChatGroupAnalyticsTest { rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 9fd059a952..837d17b214 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -171,6 +171,7 @@ class ChatGroupCashLinkTest { rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index 0b8bf0d76b..6e7414da6c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -115,6 +115,7 @@ class ChatMentionPickerTest { rosterSearch = rosterSearch, featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index d17cc2cbe3..a9dba8cd73 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -120,6 +120,7 @@ class ChatOpenTranscriptTest { rosterSearch = mockk(relaxed = true), featuredGroups = featuredGroups, lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = identifier, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 86096499e6..0804f2cbcd 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -158,6 +158,7 @@ class ChatSendFailureAnalyticsTest { rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt index 05b31400ba..553a3a267c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -119,6 +119,7 @@ class ChatTypingGateTest { rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index a955a3dfcd..12acc37c83 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -49,6 +49,15 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import com.flipcash.app.tipping.internal.HostedJoinRequests +import com.getcode.theme.White10 +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material3.Icon @@ -127,7 +136,7 @@ fun ChatsScreen() { val visible = state.visibleChats // Everything between the chips and the filler, in list order. val parkKeys: List = remember(state.chats, state.hasNoChatsAtAll, visible) { - listOf("archived") + when { + listOf("join_requests", "archived") + when { !chats.isLoaded() -> listOf("loading") state.hasNoChatsAtAll -> listOf("empty") visible.isEmpty() -> listOf("empty_filter") @@ -229,6 +238,32 @@ fun ChatsScreen() { // loading) and the parked position has a stable anchor; empty when there is no row. // Its key never leaves the list, so animateItem only moves it; the row itself fades in // and out inside the slot, while the rows below slide to make room or close the gap. + // Same shape as the Archived slot below: always emitted, empty while nobody waits. + item(key = "join_requests", contentType = "join_requests") { + Box(Modifier.animateItem().then(parkFiller.tracked("join_requests"))) { + var shown by remember { mutableStateOf(state.joinRequests) } + if (state.joinRequests != null) shown = state.joinRequests + AnimatedVisibility( + visible = state.showsJoinRequestsRow, + enter = fadeIn(), + exit = fadeOut(), + ) { + val requests = shown ?: return@AnimatedVisibility + JoinRequestsRow( + requests = requests, + onClick = { + navigator.push( + AppRoute.Messaging.Chat( + ChatIdentifier.ByChatId(requests.chatId), + openOnJoinRequests = true, + ) + ) + }, + ) + } + } + } + item(key = "archived", contentType = "archived") { Box(Modifier.animateItem().then(parkFiller.tracked("archived"))) { AnimatedVisibility( @@ -497,6 +532,69 @@ private fun ChatFilterRow( } } +/** + * Node 11214:67 — the host's way into Join Requests, pinned above Archived: "3 people want to join + * Moony" with a plain count, like Archived's, so it does not read as unread messages. + */ +@Composable +private fun JoinRequestsRow(requests: HostedJoinRequests, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .clickable(onClick = onClick) + .testTag("join_requests_row") + .padding(vertical = CodeTheme.dimens.grid.x3) + .padding(start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape) + .background(White10), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.PersonAdd, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.title_joinRequests), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = pluralStringResource( + R.plurals.subtitle_joinRequestsForGroup, + requests.count, + requests.count, + requests.groupName, + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = requests.count.toString(), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } +} + /** * The way into the archived chats. Secondary throughout (icon, label, count, chevron), so it reads * as a folder rather than one more chat; the count is the unread archived chats that aren't muted. diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index db8eb72e1c..a4e89b41a2 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -11,6 +11,12 @@ import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.LobbyRequests +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.services.models.chat.LobbyMember +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatListEntry @@ -40,6 +46,7 @@ internal class ChatsViewModel @Inject constructor( dispatchers: DispatcherProvider, private val archiveStore: ChatArchiveStore, private val lobbyRequests: LobbyRequests, + private val hostLobbies: HostLobbies, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -56,6 +63,8 @@ internal class ChatsViewModel @Inject constructor( // Whether a pull from the top has brought the chips on. Held here rather than in the // screen so it survives opening a chat and coming back. val chipsRevealed: Boolean = false, + // The pinned Join Requests row: the viewer's hosted private group with the newest request. + val joinRequests: HostedJoinRequests? = null, ) { /** The chips stay once revealed, and always show under a filter other than All. */ val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All @@ -64,6 +73,10 @@ internal class ChatsViewModel @Inject constructor( val showsArchivedRow: Boolean get() = filter == ChatListFilter.All && projection.archivedRowVisible + /** The Join Requests row: only under All, like Archived, and only while someone waits. */ + val showsJoinRequestsRow: Boolean + get() = filter == ChatListFilter.All && joinRequests != null + /** Nothing in the main list and nothing archived: the full-screen prompt. */ val hasNoChatsAtAll: Boolean get() = chats.dataOrNull.orEmpty().isEmpty() && !projection.archivedRowVisible @@ -87,6 +100,8 @@ internal class ChatsViewModel @Inject constructor( data class FilterSelected(val filter: ChatListFilter) : Event data object ChipsRevealed : Event + + data class JoinRequestsUpdated(val joinRequests: HostedJoinRequests?) : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -161,6 +176,19 @@ internal class ChatsViewModel @Inject constructor( .flowOn(dispatchers.Default) .onEach { dispatchEvent(it) } .launchIn(viewModelScope) + + // The viewer's hosted private groups, archived ones included; each one's lobby is fetched + // once per chat-list load, since LobbyUpdate only reaches a running stream. + val hostedGroups = chatCoordinator.chatListFeeds() + .map { feeds -> hostedPrivateGroups(feeds.main + feeds.archived, selfId()) } + .distinctUntilChanged() + hostedGroups + .onEach { groups -> groups.keys.forEach { chatId -> viewModelScope.launch { hostLobbies.load(chatId) } } } + .launchIn(viewModelScope) + combine(hostedGroups, hostLobbies.state, ::hostedJoinRequests) + .distinctUntilChanged() + .onEach { dispatchEvent(Event.JoinRequestsUpdated(it)) } + .launchIn(viewModelScope) } internal companion object { @@ -172,7 +200,35 @@ internal class ChatsViewModel @Inject constructor( // A chip can only be tapped once the row is on screen, so choosing one reveals it. is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } + is Event.JoinRequestsUpdated -> { state -> state.copy(joinRequests = event.joinRequests) } } } } } + +/** The chat list's Join Requests row: which group it opens, by name, and how many wait there. */ +internal data class HostedJoinRequests(val chatId: ChatId, val groupName: String, val count: Int) + +/** Private groups [selfId] created, by id, with their titles. */ +internal fun hostedPrivateGroups(summaries: List, selfId: ID?): Map { + selfId ?: return emptyMap() + return summaries + .map { it.metadata } + .filter { it.isPrivate && it.creator == selfId } + .associate { it.chatId to it.title.orEmpty() } +} + +/** + * The hosted group whose newest request is the most recent, or null when nobody waits anywhere. + * There is no frame for a switcher between hosted groups, so the row names one group and opens it. + */ +internal fun hostedJoinRequests( + hosted: Map, + lobbies: Map>, +): HostedJoinRequests? = hosted + .mapNotNull { (chatId, name) -> + val waiting = lobbies[chatId].orEmpty() + if (waiting.isEmpty()) null else Triple(chatId, name, waiting) + } + .maxByOrNull { (_, _, waiting) -> waiting.maxOf { it.enteredAt } } + ?.let { (chatId, name, waiting) -> HostedJoinRequests(chatId, name, waiting.size) } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index a76670544b..2dd6e4ea25 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -73,6 +73,7 @@ class ChatsViewModelArchiveTest { dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, ) val states = mutableListOf() diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/HostedJoinRequestsTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/HostedJoinRequestsTest.kt new file mode 100644 index 0000000000..736b849fb1 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/HostedJoinRequestsTest.kt @@ -0,0 +1,101 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.UserProfile +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.LobbyMember +import com.flipcash.shared.chat.ChatFeeds +import com.flipcash.shared.chat.ChatListFilter +import com.flipcash.shared.chat.ChatSummary +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class HostedJoinRequestsTest { + + private val self = listOf(9) + private val other = listOf(8) + + private fun group(id: Byte, title: String, creator: List?, isPrivate: Boolean = true) = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(ByteArray(16) { id }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = title, + creator = creator, + isPrivate = isPrivate, + ), + unreadCount = 0, + ) + + private fun member(id: Byte, enteredAt: Long) = LobbyMember( + userProfile = UserProfile( + displayName = "user $id", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + userId = listOf(id), + ), + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = Instant.fromEpochSeconds(enteredAt), + ) + + private val moony = group(1, "Moony", self) + private val yotes = group(2, "Yotes", self) + + @Test + fun `only private groups the viewer created count as hosted`() { + val hosted = hostedPrivateGroups( + listOf(moony, group(3, "Theirs", other), group(4, "Public", self, isPrivate = false), group(5, "Rebuilt", null)), + self, + ) + assertEquals(mapOf(moony.metadata.chatId to "Moony"), hosted) + } + + @Test + fun `no viewer, nothing hosted`() { + assertTrue(hostedPrivateGroups(listOf(moony), null).isEmpty()) + } + + @Test + fun `no row while nobody waits`() { + val hosted = hostedPrivateGroups(listOf(moony, yotes), self) + assertNull(hostedJoinRequests(hosted, emptyMap())) + assertNull(hostedJoinRequests(hosted, mapOf(moony.metadata.chatId to emptyList()))) + } + + @Test + fun `the row names the group with the newest request and counts its lobby`() { + val hosted = hostedPrivateGroups(listOf(moony, yotes), self) + val row = hostedJoinRequests( + hosted, + mapOf( + moony.metadata.chatId to listOf(member(1, 100), member(2, 150), member(3, 120)), + yotes.metadata.chatId to listOf(member(4, 300)), + ), + ) + assertEquals(HostedJoinRequests(yotes.metadata.chatId, "Yotes", 1), row) + } + + @Test + fun `a lobby for a group the viewer does not host is ignored`() { + val theirs = group(3, "Theirs", other) + val hosted = hostedPrivateGroups(listOf(moony, theirs), self) + assertNull(hostedJoinRequests(hosted, mapOf(theirs.metadata.chatId to listOf(member(1, 100))))) + } + + @Test + fun `the row shows only under All`() { + val row = HostedJoinRequests(moony.metadata.chatId, "Moony", 2) + assertTrue(ChatsViewModel.State(joinRequests = row).showsJoinRequestsRow) + assertFalse(ChatsViewModel.State(joinRequests = row, filter = ChatListFilter.Unread).showsJoinRequestsRow) + assertFalse(ChatsViewModel.State().showsJoinRequestsRow) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt new file mode 100644 index 0000000000..73713c3d78 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt @@ -0,0 +1,117 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.DenyLobbyMemberError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.services.models.chat.LobbyUpdate +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 javax.inject.Inject +import javax.inject.Singleton + +/** Where the event stream hands a private group's lobby changes. */ +fun interface LobbyUpdateSink { + fun apply(chatId: ChatId, updates: List) + + /** Drops everything held. For logout: the lobbies belong to the account. */ + fun clear() = Unit + + object None : LobbyUpdateSink { + override fun apply(chatId: ChatId, updates: List) = Unit + } +} + +/** + * The lobbies of the private groups this account created: who is waiting to join each, newest + * first. One copy for every host surface (profile card, chat-list row, Join Requests, in-chat + * anchor) so their counts agree. + * + * [load] fetches a lobby whole; the event stream's LobbyUpdates are applied on top through + * [apply]. Those updates are unversioned and best-effort, so a surface that needs the lobby to be + * right calls [load] again. + */ +@Singleton +class HostLobbies @Inject constructor( + private val chatController: ChatController, + private val keyEnvelopes: GroupKeyEnvelopes, +) : LobbyUpdateSink { + + private val lobbies = MutableStateFlow>>(emptyMap()) + + /** Each loaded lobby's waiting members, newest first. A chat never loaded is absent. */ + val state: StateFlow>> = lobbies.asStateFlow() + + /** Fetches [chatId]'s whole lobby, every page, and replaces what was held for it. */ + suspend fun load(chatId: ChatId): Result> = runCatching { + val members = mutableListOf() + var options = QueryOptions() + do { + val page = chatController.getLobbyMembers(chatId, options).getOrThrow() + members += page.members + options = options.copy(token = page.pagingToken) + } while (page.hasMore && page.pagingToken != null) + newestFirst(members.distinctBy { it.userProfile.userId }) + }.onSuccess { members -> + lobbies.update { it + (chatId to members) } + } + + override fun apply(chatId: ChatId, updates: List) { + lobbies.update { all -> + val current = all[chatId].orEmpty() + val next = updates.fold(current) { members, update -> + when (update) { + is LobbyUpdate.MemberEntered -> + members.filterNot { it.userProfile.userId == update.member.userProfile.userId } + + update.member + is LobbyUpdate.MemberLeft -> + members.filterNot { it.userProfile.userId == update.userId } + } + } + all + (chatId to newestFirst(next)) + } + } + + /** + * Wraps the chat key for [member] and admits them. A member who already left the lobby + * (NOT_IN_LOBBY) is dropped from the list and counts as done: there is nothing left to act on. + */ + suspend fun approve(chatId: ChatId, member: LobbyMember): Result { + val userId = member.userProfile.userId + ?: return Result.failure(IllegalArgumentException("Lobby member has no user id")) + val result = keyEnvelopes.wrapFor(chatId, member.publicKey.byteArray) + .mapCatching { envelope -> + chatController.admitLobbyMember(chatId, userId, envelope).getOrThrow() + } + .recoverCatching { if (it !is AdmitLobbyMemberError.NotInLobby) throw it } + if (result.isSuccess) remove(chatId, userId) + return result + } + + /** Turns [userId] away. One already gone from the lobby (NOT_FOUND) counts as done. */ + suspend fun deny(chatId: ChatId, userId: ID): Result { + val result = chatController.denyLobbyMember(chatId, userId) + .recoverCatching { if (it !is DenyLobbyMemberError.NotFound) throw it } + if (result.isSuccess) remove(chatId, userId) + return result + } + + override fun clear() { + lobbies.value = emptyMap() + } + + private fun remove(chatId: ChatId, userId: ID) { + lobbies.update { all -> + val current = all[chatId] ?: return@update all + all + (chatId to current.filterNot { it.userProfile.userId == userId }) + } + } + + private fun newestFirst(members: List) = members.sortedByDescending { it.enteredAt } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index b0a6f13745..97e079030a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -3,7 +3,9 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.LobbyRequestStore +import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption @@ -51,6 +53,11 @@ abstract class ChatModule { impl: DataStoreLobbyRequestStore ): LobbyRequestStore + @Binds + abstract fun bindLobbyUpdateSink( + impl: HostLobbies + ): LobbyUpdateSink + @Binds @Singleton abstract fun bindChatMediaSending( 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 b36bf841c5..91cbde2e18 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.LobbyUpdateSink import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatState @@ -101,6 +102,7 @@ class RealChatCoordinator @Inject constructor( private val dispatchers: DispatcherProvider, private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, private val mediaSender: ChatMediaSending = ChatMediaSending.None, + private val lobbyUpdates: LobbyUpdateSink = LobbyUpdateSink.None, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -221,6 +223,8 @@ class RealChatCoordinator @Inject constructor( messagingDelegate.loadMessages(event.chatId) is EventStreamDelegate.Event.RosterChanged -> groupFeedDelegate.applyRosterChanges(event.chatId, event.changes) + is EventStreamDelegate.Event.LobbyChanged -> + lobbyUpdates.apply(event.chatId, event.updates) } }.launchIn(scope) @@ -418,6 +422,7 @@ class RealChatCoordinator @Inject constructor( stateHolder.reset() eventStreamDelegate.clearAll() reactionsDelegate.clearAll() + lobbyUpdates.clear() cluster.value = null supervisorJob.cancel() trace(tag = TAG, message = "teardown complete", type = TraceType.Process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 5888150319..5859f26b7d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -19,6 +19,7 @@ import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.models.chat.LobbyUpdate import com.flipcash.services.models.chat.RosterChange import com.flipcash.shared.chat.reactions.ReactionState import com.flipcash.services.models.GetDeltaError @@ -115,6 +116,9 @@ class EventStreamDelegate @Inject constructor( * delegate's, and this delegate has no business knowing what a membership means. */ data class RosterChanged(val chatId: ChatId, val changes: List) : Event + + /** [chatId]'s lobby changed. Sent only to the group's creator; unversioned. */ + data class LobbyChanged(val chatId: ChatId, val updates: List) : Event } private val _events = Channel(Channel.UNLIMITED) @@ -458,6 +462,12 @@ class EventStreamDelegate @Inject constructor( _events.send(Event.RosterChanged(chatId, update.rosterUpdates)) } + // --- Lobby changes --- + + if (update.lobbyUpdates.isNotEmpty()) { + _events.send(Event.LobbyChanged(chatId, update.lobbyUpdates)) + } + // --- Process reaction updates --- if (update.reactionUpdates.isNotEmpty()) { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt new file mode 100644 index 0000000000..d2c48bfd32 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt @@ -0,0 +1,139 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.DenyLobbyMemberError +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.services.models.chat.LobbyPage +import com.flipcash.services.models.chat.LobbyUpdate +import com.getcode.solana.keys.PublicKey +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +class HostLobbiesTest { + + private val chatController = mockk() + private val keyEnvelopes = mockk() + private val lobbies = HostLobbies(chatController, keyEnvelopes) + + private val chat = ChatId("aa") + private val envelope = KeyEnvelope(scheme = 1, nonce = ByteArray(12), ciphertext = ByteArray(48)) + + private fun member(id: Byte, enteredAt: Long) = LobbyMember( + userProfile = UserProfile( + displayName = "user $id", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + userId = listOf(id), + ), + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = Instant.fromEpochSeconds(enteredAt), + ) + + private val older = member(1, 100) + private val newer = member(2, 200) + + private fun ids() = lobbies.state.value[chat].orEmpty().map { it.userProfile.userId } + + @Test + fun `load reads every page and orders newest first`() = runTest { + val token: PagingToken = listOf(7) + coEvery { chatController.getLobbyMembers(chat, match { it.token == null }) } returns + Result.success(LobbyPage(listOf(older), token, hasMore = true)) + coEvery { chatController.getLobbyMembers(chat, match { it.token == token }) } returns + Result.success(LobbyPage(listOf(newer), null, hasMore = false)) + + lobbies.load(chat).getOrThrow() + + assertEquals(listOf(listOf(2), listOf(1)), ids()) + } + + @Test + fun `a failed load keeps what was held`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { chatController.getLobbyMembers(chat, any()) } returns Result.failure(IllegalStateException()) + + assertTrue(lobbies.load(chat).isFailure) + + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `stream updates add once and remove by user`() { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older), LobbyUpdate.MemberEntered(newer))) + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(newer))) + assertEquals(listOf(listOf(2), listOf(1)), ids()) + + lobbies.apply(chat, listOf(LobbyUpdate.MemberLeft(listOf(2)))) + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `approve wraps the key for the member and admits them`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { keyEnvelopes.wrapFor(chat, any()) } returns Result.success(envelope) + coEvery { chatController.admitLobbyMember(chat, listOf(1), envelope) } returns Result.success(Unit) + + lobbies.approve(chat, older).getOrThrow() + + coVerify { chatController.admitLobbyMember(chat, listOf(1), envelope) } + assertEquals(emptyList(), ids()) + } + + @Test + fun `approving someone who already left drops them without an error`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { keyEnvelopes.wrapFor(chat, any()) } returns Result.success(envelope) + coEvery { chatController.admitLobbyMember(chat, any(), any()) } returns + Result.failure(AdmitLobbyMemberError.NotInLobby()) + + assertTrue(lobbies.approve(chat, older).isSuccess) + assertEquals(emptyList(), ids()) + } + + @Test + fun `a key that cannot be wrapped admits no one and keeps the row`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { keyEnvelopes.wrapFor(chat, any()) } returns Result.failure(IllegalStateException()) + + assertTrue(lobbies.approve(chat, older).isFailure) + + coVerify(exactly = 0) { chatController.admitLobbyMember(any(), any(), any()) } + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `deny removes the row, and one already gone counts as done`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older), LobbyUpdate.MemberEntered(newer))) + coEvery { chatController.denyLobbyMember(chat, listOf(1)) } returns Result.success(Unit) + coEvery { chatController.denyLobbyMember(chat, listOf(2)) } returns + Result.failure(DenyLobbyMemberError.NotFound()) + + assertTrue(lobbies.deny(chat, listOf(1)).isSuccess) + assertTrue(lobbies.deny(chat, listOf(2)).isSuccess) + + assertEquals(emptyList(), ids()) + } + + @Test + fun `a failed deny keeps the row`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { chatController.denyLobbyMember(chat, any()) } returns + Result.failure(DenyLobbyMemberError.Denied()) + + assertTrue(lobbies.deny(chat, listOf(1)).isFailure) + assertEquals(listOf(listOf(1)), ids()) + } +} From 4f91358a538466b3ea4dd9738a259b37882b1297 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:03:19 -0400 Subject: [PATCH 07/25] feat(chat): show pending join requests under a private group's top bar The host of a private group sees a glass card under the chat top bar while anyone is waiting: up to three avatars, " wants to join" with the request's age for one requester, or "N people want to join" with the first names, and a Review button that opens Join Requests. It reads the host lobby store from the previous slice, so it hides as soon as the last request is approved, denied or withdrawn. The card sits in the scaffold's top slot under ChatTopBar, so the transcript's top inset grows by its height. It blurs the transcript with the same haze recipe as ComposerReplyStrip and draws flat when there is no HazeState. Figma nodes 11354:70, 11354:81, 11354:83. --- .../core/src/main/res/values/strings.xml | 10 + .../app/messenger/internal/ChatViewModel.kt | 7 + .../internal/screens/MessengerScreen.kt | 45 ++++- .../screens/components/JoinRequestsAnchor.kt | 189 ++++++++++++++++++ .../components/JoinRequestsAnchorTest.kt | 49 +++++ 5 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchorTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 56ef011003..e461702452 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1147,6 +1147,16 @@ No requests right now When someone asks to join %1$s, they\'ll show up here Share Group + + %1$s wants to join + + %1$d person wants to join + %1$d people want to join + + and + Requested just now + Requested yesterday + Review Only people you approve can join. Messages are end-to-end encrypted. Find Someone by Username diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index fbd8f58e4c..9ec8cf26b7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -586,6 +586,13 @@ internal class ChatViewModel @AssistedInject constructor( val obscuresTranscript: Boolean get() = isOutsideGroup && !readsFromOutside + /** + * The host's Join Requests anchor under the title bar (node 11354:70): only while someone is + * waiting, and not while a message action has taken the bar over. + */ + val showsJoinRequestsAnchor: Boolean + get() = isLobbyHost && joinRequests.isNotEmpty() && selection == null && editing == null + /** Whether the chat's speaker rules let this viewer speak. See [speakerBlock]. */ val canSpeak: Boolean get() = speakerBlock == null diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index f4eba09467..7e7d9ef761 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -2,12 +2,19 @@ package com.flipcash.app.messenger.internal.screens import android.os.Parcelable import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import com.flipcash.shared.chat.ui.media.ChatPhotoOverlay @@ -52,8 +59,10 @@ import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge +import com.flipcash.app.messenger.internal.screens.components.JoinRequestsAnchor import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar +import com.getcode.theme.CodeTheme import com.getcode.ui.components.BlurredContent import com.flipcash.shared.chat.models.ChatAction import com.flipcash.app.core.LocalUserManager @@ -376,13 +385,35 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // against the background at its own edge. barPlacement = ScaffoldBarPlacement.Overlay, topBar = { - ChatTopBar( - navigator = navigator, - state = state, - onBarHeightChange = { barHeight = it }, - chatActionHandler = chatActionHandler, - dispatch = viewModel::dispatchEvent, - ) + // The anchor rides in the top slot rather than over the list, so the transcript's top + // inset grows by its height and the oldest message clears it. barHeight stays the bar's + // own: the soft top edge belongs to the bar, and the anchor is glass over the scroll. + Column { + ChatTopBar( + navigator = navigator, + state = state, + onBarHeightChange = { barHeight = it }, + chatActionHandler = chatActionHandler, + dispatch = viewModel::dispatchEvent, + ) + // Hold the last list through the exit, so the card doesn't empty as it slides away. + var lastRequests by remember { mutableStateOf(state.joinRequests) } + if (state.joinRequests.isNotEmpty()) lastRequests = state.joinRequests + AnimatedVisibility( + visible = state.showsJoinRequestsAnchor, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + ) { + JoinRequestsAnchor( + requests = lastRequests, + onReview = { keyboard.hideIfVisible { navigator.push(ChatStep.JoinRequests) } }, + hazeState = hazeState, + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2), + ) + } + } }, bottomBar = { // Behind the backdrop with the transcript while a message is raised, dimmed and blurred diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt new file mode 100644 index 0000000000..8cc6279a47 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt @@ -0,0 +1,189 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +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.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.flipcash.app.messenger.internal.screens.profile.RequestAge +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInputDefaults +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Nodes 11354:70 / 11354:81 / 11354:83 — the host's way into Join Requests from inside the chat: up + * to three requesters' avatars, "3 people want to join" over their names (or "Jasper wants to join" + * over the request's age), and a Review capsule. The whole card is the tap target. + * + * The ground is [com.flipcash.shared.chat.ui.ComposerReplyStrip]'s: the composer's ultra-thin haze + * over the background with the divider hairline, so the transcript reads through it as it scrolls + * under, and the same flat stand-in when there is no [HazeState] to sample. + */ +@Composable +internal fun JoinRequestsAnchor( + requests: List, + onReview: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val shape = CodeTheme.shapes.medium + val ground = if (hazeState != null) { + Modifier + .hazeBlur( + HazeInput.Sources(hazeState), + HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background), + ) + .background(ChatInputDefaults.ContainerColor) + } else { + Modifier.background(Color.White.copy(alpha = 0.1f)) + } + + val unknownName = stringResource(R.string.title_unnamedUser) + val names = requests.map { chatterName(it.userProfile.displayName, it.userProfile.username, unknownName) } + val andWord = stringResource(R.string.subtitle_namesAnd) + + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .then(ground) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .clickable(onClick = onReview) + .heightIn(min = AnchorMinHeight) + .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x1) + .testTag("join_requests_anchor"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + AvatarStack(requests.take(MaxAvatars)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (requests.size == 1) { + stringResource(R.string.title_wantsToJoin, names.first()) + } else { + pluralStringResource(R.plurals.title_peopleWantToJoin, requests.size, requests.size) + }, + style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (requests.size == 1) { + requestedAgo(requests.first()) + } else { + joinNames(names) { head, last -> "$head $andWord $last" } + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Approve's capsule on Join Requests. The card is the target, so this one only shows where. + CodeButton( + text = stringResource(R.string.action_review), + buttonState = ButtonState.Filled, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp), + overrideContentPadding = true, + onClick = onReview, + ) + } +} + +/** Newest first, overlapping, each ringed in the background so the overlap reads as a cut. */ +@Composable +private fun AvatarStack(members: List) { + val ring = CodeTheme.colors.background + Box { + members.forEachIndexed { index, member -> + ContactAvatar( + userProfile = member.userProfile, + modifier = Modifier + .offset(x = (AvatarSize - AvatarOverlap) * index) + // The newest sits on top of the ones after it. + .zIndex((members.size - index).toFloat()) + .size(AvatarSize) + .border(AvatarRing, ring, CircleShape) + .padding(AvatarRing) + .clip(CircleShape), + ) + } + // The offsets don't take up width in a Box, so reserve what the stack spans. + Box(Modifier.size(width = AvatarSize + (AvatarSize - AvatarOverlap) * (members.size - 1).coerceAtLeast(0), height = AvatarSize)) + } +} + +@Composable +private fun requestedAgo(member: LobbyMember): String { + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + return when (val age = RequestAge.of(member.enteredAt, now)) { + RequestAge.JustNow -> stringResource(R.string.title_requestedJustNow) + is RequestAge.Minutes -> stringResource(R.string.action_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.action_requestedHoursAgo, age.count) + is RequestAge.Days -> if (age.count == 1L) { + stringResource(R.string.title_requestedYesterday) + } else { + stringResource(R.string.action_requestedDaysAgo, age.count) + } + } +} + +/** "Jasper", "Jasper and Tam", "Jasper, Vincent and Tam": the last name joined by [and]. */ +internal fun joinNames(names: List, and: (head: String, last: String) -> String): String = + when (names.size) { + 0 -> "" + 1 -> names.first() + else -> and(names.dropLast(1).joinToString(", "), names.last()) + } + +private const val MaxAvatars = 3 +private val AvatarSize = 30.dp +private val AvatarRing = 2.dp +private val AvatarOverlap = 10.dp +private val AnchorMinHeight = 50.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchorTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchorTest.kt new file mode 100644 index 0000000000..cdad5893ee --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchorTest.kt @@ -0,0 +1,49 @@ +package com.flipcash.app.messenger.internal.screens.components + +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.services.models.chat.LobbyMember +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JoinRequestsAnchorTest { + + private val and = { head: String, last: String -> "$head and $last" } + + @Test + fun `no names is empty`() = assertEquals("", joinNames(emptyList(), and)) + + @Test + fun `one name stands alone`() = assertEquals("Jasper", joinNames(listOf("Jasper"), and)) + + @Test + fun `two names are joined by and`() = + assertEquals("Jasper and Tam", joinNames(listOf("Jasper", "Tam"), and)) + + @Test + fun `the last of several is joined by and, the rest by commas`() = + assertEquals( + "Jasper, Vincentjeezus24 and Tam", + joinNames(listOf("Jasper", "Vincentjeezus24", "Tam"), and), + ) + + @Test + fun `the host sees the anchor while someone is waiting`() { + val state = ChatViewModel.State(isLobbyHost = true, joinRequests = listOf(mockk())) + assertTrue(state.showsJoinRequestsAnchor) + } + + @Test + fun `the anchor hides at zero`() { + val state = ChatViewModel.State(isLobbyHost = true, joinRequests = emptyList()) + assertFalse(state.showsJoinRequestsAnchor) + } + + @Test + fun `only the host sees the anchor`() { + val state = ChatViewModel.State(isLobbyHost = false, joinRequests = listOf(mockk())) + assertFalse(state.showsJoinRequestsAnchor) + } +} From 7a60eb93def3f15b58f6beace5e8520a211b24a9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:21:17 -0400 Subject: [PATCH 08/25] feat(chat): private group members, encryption sheet and leave copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member's private-group profile shows a Members grid with a count that opens a full list (host pinned with a Host tag, "Joined …" lines). GroupRosters pages GetRoster and applies RosterUpdate by version, fed from the same path as RosterStateHolder. The Encryption menu item gets a filled lock for a private group and opens a private-group E2EE sheet (node 11210:80). The Reactions row is left out until its encryption status is confirmed. Leaving a private group warns that rejoining needs the host's approval. Dialog copy added on this branch (leave, lobby full, too many lobbies, request/cancel/approve/deny failures) drops its trailing period. GetRoster currently returns UNAVAILABLE ("roster reads are disabled"), so the grid stays hidden until the server enables it. --- .../ui/navigation/AppScreenContent.kt | 3 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 8 + .../com/flipcash/app/core/chat/ChatStep.kt | 8 + .../core/src/main/res/values/strings.xml | 34 +++- .../flipcash/app/messenger/ChatFlowScreen.kt | 4 + .../app/messenger/E2eeLearnMoreScreen.kt | 11 +- .../app/messenger/internal/ChatViewModel.kt | 57 +++++- .../internal/screens/E2eeLearnMoreSheet.kt | 47 +++-- .../screens/profile/GroupProfileMenuItems.kt | 6 +- .../screens/profile/GroupProfileScreen.kt | 55 +++++- .../screens/profile/GroupProfileState.kt | 41 ++++ .../internal/screens/profile/MembersScreen.kt | 176 ++++++++++++++++++ .../messenger/internal/ChatEventStallTest.kt | 1 + .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatGroupCashLinkTest.kt | 1 + .../internal/ChatMentionPickerTest.kt | 1 + .../internal/ChatOpenTranscriptTest.kt | 1 + .../internal/ChatSendFailureAnalyticsTest.kt | 1 + .../messenger/internal/ChatTypingGateTest.kt | 1 + .../profile/PrivateGroupMembersTest.kt | 72 +++++++ .../com/flipcash/shared/chat/GroupRosters.kt | 89 +++++++++ .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../chat/internal/RealChatCoordinator.kt | 7 +- .../flipcash/shared/chat/GroupRostersTest.kt | 112 +++++++++++ .../common/ui/profile/GroupChattingGrid.kt | 42 ++++- 25 files changed, 750 insertions(+), 36 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupMembersTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRosters.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRostersTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index c0bb84fd03..088878fc7c 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -151,6 +151,9 @@ fun appEntryProvider( annotatedEntry { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) } annotatedEntry { E2eeLearnMoreScreen(forGroup = false) } annotatedEntry { E2eeLearnMoreScreen(forGroup = true) } + annotatedEntry { + E2eeLearnMoreScreen(forGroup = true, isPrivate = true) + } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 34a8cb5630..3a393dc375 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -537,6 +537,14 @@ sealed interface AppRoute : NavKey, Parcelable { Messaging, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + + /** [E2eeGroupInfo] for a private group, which is end-to-end encrypted (node 11210:71). */ + @Serializable + @Parcelize + data object E2eePrivateGroupInfo : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet } @Serializable 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 4500a7360f..5b37ae5731 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 @@ -110,6 +110,14 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object JoinRequests : ChatStep + /** + * Node 11210:70 — every member of a private group, the creator pinned first with a Host tag. + * The roster comes from the shared roster store. + */ + @Parcelize + @Serializable + data object Members : ChatStep + /** The group's title, on the same entry screen shape the profile name uses. */ @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 e461702452..d9b19c6759 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -932,6 +932,8 @@ Add %1$s to Chat Leave Chat Leave %1$s? + + You’ll lose access to the conversation. To rejoin, you’ll need to request again and wait for the host to approve You won’t receive messages from this group any more. You can join again with an invite link Something Went Wrong We were unable to leave this group. Please try again @@ -1020,6 +1022,10 @@ Messages sent before encryption became available Messages and media in 1:1 chats Public group chats, including this one + + Messages and media in this group are end-to-end encrypted. Only its members can read them, and the host approves everyone who joins. + The group’s name, photo, description and cover + Who is in the group, and how many Cash @@ -1117,16 +1123,16 @@ You\'ll be able to open the chat once %1$s approves You\'ll be able to open the chat once the host approves %1$s isn\'t taking requests right now - Too many people are waiting for the host to respond. Try again later. - You have too many pending requests. Cancel one to request another. + Too many people are waiting for the host to respond. Try again later + You have too many pending requests. Cancel one to request another Couldn\'t Send Request - Something went wrong. Please try again. + Something went wrong. Please try again Couldn\'t Cancel Request - Something went wrong. Please try again. + Something went wrong. Please try again Couldn\'t Approve - Something went wrong. Please try again. + Something went wrong. Please try again Couldn\'t Deny - Something went wrong. Please try again. + Something went wrong. Please try again Join Requests %1$d person is waiting @@ -1157,6 +1163,22 @@ Requested just now Requested yesterday Review + + Members + + %1$d MEMBER + %1$d MEMBERS + + Host + Joined just now + Joined %1$dm ago + Joined %1$dh ago + Joined yesterday + Joined %1$dd ago + Joined last week + Joined %1$d weeks ago + Joined last month + Joined %1$d months ago Only people you approve can join. Messages are end-to-end encrypted. Find Someone by Username 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 c558622018..cb4c7ef8e3 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,6 +39,7 @@ import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupCoverScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupDescriptionScreen import com.flipcash.app.messenger.internal.screens.profile.JoinRequestsScreen +import com.flipcash.app.messenger.internal.screens.profile.MembersScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen @@ -123,6 +124,9 @@ private fun chatEntryProvider( annotatedEntry { JoinRequestsScreen(flowSharedViewModel()) } + annotatedEntry { + MembersScreen(flowSharedViewModel()) + } annotatedEntry { FlowEditGroupNameScreen() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt index e9606e3d95..b4dbbda6bc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt @@ -6,14 +6,19 @@ import com.flipcash.app.messenger.internal.screens.E2eeSheetKind import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher /** - * The DM or group encryption explainer, behind `AppRoute.Messaging.E2eeDmInfo` and `E2eeGroupInfo`. + * The DM or group encryption explainer, behind `AppRoute.Messaging.E2eeDmInfo`, `E2eeGroupInfo` and + * `E2eePrivateGroupInfo`. */ @Composable -fun E2eeLearnMoreScreen(forGroup: Boolean) { +fun E2eeLearnMoreScreen(forGroup: Boolean, isPrivate: Boolean = false) { // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. val dismissSheet = LocalBottomSheetDismissDispatcher.current E2eeLearnMoreSheet( - kind = if (forGroup) E2eeSheetKind.Group else E2eeSheetKind.Dm, + kind = when { + !forGroup -> E2eeSheetKind.Dm + isPrivate -> E2eeSheetKind.PrivateGroup + else -> E2eeSheetKind.Group + }, onDismiss = dismissSheet, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 9ec8cf26b7..5c219c69c3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -69,6 +69,7 @@ import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequireme import com.flipcash.app.messenger.internal.screens.profile.GroupProfileStanding import com.flipcash.app.messenger.internal.screens.profile.JoinRequestCall import com.flipcash.app.messenger.internal.screens.profile.LobbyCall +import com.flipcash.app.messenger.internal.screens.profile.privateGroupMembers import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile @@ -96,6 +97,7 @@ import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.FeaturedGroupsStore +import com.flipcash.shared.chat.GroupRosters import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.LobbyRequests import com.flipcash.shared.chat.media.ChatMediaUploadState @@ -267,6 +269,7 @@ internal class ChatViewModel @AssistedInject constructor( private val featuredGroups: FeaturedGroupsStore, private val lobbyRequests: LobbyRequests, private val hostLobbies: HostLobbies, + private val groupRosters: GroupRosters, private val dispatchers: DispatcherProvider, // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, @@ -486,6 +489,8 @@ internal class ChatViewModel @AssistedInject constructor( val isLobbyHost: Boolean = false, /** Who is waiting to join, newest first. Empty unless [isLobbyHost]. */ val joinRequests: List = emptyList(), + /** A private group's full roster, creator first, for its Members grid and list. */ + val members: List = emptyList(), /** Approve or Deny calls out, by the requester's user id, so only that row waits. */ val joinRequestCalls: Map = emptyMap(), /** @@ -793,6 +798,12 @@ internal class ChatViewModel @AssistedInject constructor( data class DenyJoinRequest(val member: LobbyMember) : Event data class JoinRequestCallChanged(val userId: ID, val call: JoinRequestCall?) : Event data class OnChattersLoaded(val chatters: List) : Event + + /** A private group's roster from [GroupRosters], already in display order. */ + data class OnMembersResolved(val members: List) : Event + + /** The Members list came on screen: refetch, since a roster read can lag the stream. */ + data object MembersOpened : Event data class OnProfileStandingResolved(val standing: GroupProfileStanding?) : Event data class OnRuleTokensResolved(val tokens: Map, RuleCurrency>) : Event @@ -2559,7 +2570,14 @@ internal class ChatViewModel @AssistedInject constructor( R.string.prompt_title_leaveChat, group.title, ), - message = resources.getString(R.string.prompt_description_leaveChat), + // A private group has no invite link back in: rejoining is a new request. + message = resources.getString( + if (group.isPrivate) { + R.string.prompt_description_leavePrivateChat + } else { + R.string.prompt_description_leaveChat + } + ), actions = listOf( BottomBarAction(text = resources.getString(R.string.action_leaveChat)) { dispatchEvent(Event.LeaveConfirmed) @@ -2619,6 +2637,41 @@ internal class ChatViewModel @AssistedInject constructor( .onEach { dispatchEvent(it) } .launchIn(viewModelScope) + // A private group's roster, for its members only (GetRoster is denied to a requester). + // Loaded when the viewer turns out to be a member and again each time the profile or the + // Members list opens; RosterUpdate keeps it current in between. + stateFlow.map { state -> + (state.subject as? ChatSubject.Group) + ?.takeIf { it.isPrivate && it.isMember == true } + ?.let { it.chatId to it.creator } + } + .distinctUntilChanged() + .flatMapLatest { group -> + if (group == null) { + flowOf(Event.OnMembersResolved(emptyList())) + } else { + val (chatId, creator) = group + viewModelScope.launch { + groupRosters.load(chatId) + .onFailure { trace("failed to load roster - ${it.localizedMessage}") } + } + groupRosters.state.map { + Event.OnMembersResolved(privateGroupMembers(it[chatId].orEmpty(), creator)) + } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + merge( + eventFlow.filterIsInstance(), + eventFlow.filterIsInstance(), + ).onEach { + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + if (group.isPrivate && group.isMember == true) groupRosters.load(group.chatId) + }.launchIn(viewModelScope) + eventFlow.filterIsInstance() .onEach { val chatId = stateFlow.value.chatId ?: return@onEach @@ -3383,6 +3436,8 @@ internal class ChatViewModel @AssistedInject constructor( is Event.OnLobbyRequestResolved -> { state -> state.copy(lobbyRequestedAt = event.requestedAt) } is Event.OnHostNameResolved -> { state -> state.copy(hostName = event.name) } is Event.OnChattersLoaded -> { state -> state.copy(chatters = event.chatters) } + is Event.OnMembersResolved -> { state -> state.copy(members = event.members) } + Event.MembersOpened -> { state -> state } is Event.OnProfileStandingResolved -> { state -> state.copy(profileStanding = event.standing) } is Event.OnRuleTokensResolved -> { state -> state.copy(ruleTokens = event.tokens) } Event.LeaveConfirmed -> { state -> state.copy(leaving = true) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt index e2bbef3862..96da76ef7d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt @@ -37,35 +37,49 @@ import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.BulletRow /** Which chat kind the learn-more sheet explains. */ -internal enum class E2eeSheetKind { Dm, Group } +internal enum class E2eeSheetKind { Dm, Group, PrivateGroup } /** * What end-to-end encryption does and doesn't cover, opened from the profile footer. One layout - * for both kinds, with the DM's and the group's own words (nodes 10416:1533, 10557:1412). + * for every kind, with the DM's, the public group's and the private group's own words (nodes + * 10416:1533, 10557:1412, 11210:71). + * + * The private group's frame also lists "Reactions, and who sent them" as not encrypted. Nobody + * has confirmed reactions in a private group behave as in a DM, so that row is left out. */ @Composable internal fun E2eeLearnMoreSheet( kind: E2eeSheetKind, onDismiss: () -> Unit, ) { - val isDm = kind == E2eeSheetKind.Dm - val encrypted = if (isDm) { + val isEncrypted = kind != E2eeSheetKind.Group + val title = if (isEncrypted) R.string.title_e2eeSheet_dm else R.string.title_e2eeSheet_group + val subtitle = when (kind) { + E2eeSheetKind.Dm -> R.string.subtitle_e2eeSheet_dm + E2eeSheetKind.Group -> R.string.subtitle_e2eeSheet_group + E2eeSheetKind.PrivateGroup -> R.string.subtitle_e2eeSheet_privateGroup + } + val encrypted = if (isEncrypted) { listOf(R.string.item_e2eeSheet_allMessageText, R.string.item_e2eeSheet_allMedia) } else { listOf(R.string.item_e2eeSheet_messagesAndMediaInDms) } - val notEncrypted = if (isDm) { - listOf( + val notEncrypted = when (kind) { + E2eeSheetKind.Dm -> listOf( R.string.item_e2eeSheet_payments, R.string.item_e2eeSheet_reactions, R.string.item_e2eeSheet_publicGroupChats, R.string.item_e2eeSheet_beforeEncryption, ) - } else { - listOf( + E2eeSheetKind.Group -> listOf( R.string.item_e2eeSheet_publicGroupChatsIncludingThis, R.string.item_e2eeSheet_payments, ) + E2eeSheetKind.PrivateGroup -> listOf( + R.string.item_e2eeSheet_groupDetails, + R.string.item_e2eeSheet_groupMembers, + R.string.item_e2eeSheet_payments, + ) } Column( @@ -87,7 +101,7 @@ internal fun E2eeLearnMoreSheet( ) { Icon( modifier = Modifier.size(32.dp), - imageVector = if (isDm) Icons.Filled.Lock else Icons.Filled.LockOpen, + imageVector = if (isEncrypted) Icons.Filled.Lock else Icons.Filled.LockOpen, contentDescription = null, tint = Color.White.copy(alpha = 0.7f), ) @@ -97,9 +111,7 @@ internal fun E2eeLearnMoreSheet( modifier = Modifier .padding(horizontal = CodeTheme.dimens.inset) .padding(top = 20.dp), - text = stringResource( - if (isDm) R.string.title_e2eeSheet_dm else R.string.title_e2eeSheet_group - ), + text = stringResource(title), style = CodeTheme.typography.textLarge.copy( fontSize = 22.sp, fontWeight = FontWeight.SemiBold, @@ -111,9 +123,7 @@ internal fun E2eeLearnMoreSheet( modifier = Modifier .padding(horizontal = 24.dp) .padding(top = 16.dp), - text = stringResource( - if (isDm) R.string.subtitle_e2eeSheet_dm else R.string.subtitle_e2eeSheet_group - ), + text = stringResource(subtitle), style = CodeTheme.typography.textSmall.copy( fontSize = 15.sp, lineHeight = 21.sp, @@ -196,3 +206,10 @@ private fun Preview_E2eeSheet_Dm() { private fun Preview_E2eeSheet_Group() { E2eeLearnMoreSheet(kind = E2eeSheetKind.Group, onDismiss = {}) } + +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_E2eeSheet_PrivateGroup() { + E2eeLearnMoreSheet(kind = E2eeSheetKind.PrivateGroup, onDismiss = {}) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index 8e05d43951..fe8815403b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.ui.graphics.vector.ImageVector @@ -28,8 +29,9 @@ internal enum class GroupProfileMenuAction { Report -> R.string.title_report } - fun icon(): ImageVector = when (this) { - Encryption -> Icons.Outlined.LockOpen + /** A private group is end-to-end encrypted, so its Encryption row shows a closed lock. */ + fun icon(isPrivate: Boolean = false): ImageVector = when (this) { + Encryption -> if (isPrivate) Icons.Filled.Lock else Icons.Outlined.LockOpen Mute -> Icons.Outlined.NotificationsOff Report -> Icons.Outlined.Feedback } 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 e57675b1b8..dc8adfe399 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 @@ -290,6 +290,45 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { ) } } + // A private group's members see its roster instead of a sample: creator first, + // then the newest joins, with the count opening the full list (node 11210:68). + val members = remember(state.members, unknownName) { + state.members.take(MembersGridLimit).map { member -> + val profile = member.userProfile + ChattingGridItem( + userId = member.userId, + name = chatterName(profile.displayName, profile.username, unknownName), + picture = profile.profilePicture, + isHost = member.userId == group?.creator, + ) + } + } + AnimatedVisibility( + visible = group?.isPrivate == true && members.isNotEmpty(), + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + ) { + GroupChattingGrid( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x6), + chatters = members, + title = stringResource(R.string.title_members), + count = (group?.memberCount ?: state.members.size.toLong()).toString(), + onShowAll = { flowNavigator.navigateTo(ChatStep.Members) }, + onOpen = { item -> + val member = state.members.firstOrNull { it.userId == item.userId } + ?: return@GroupChattingGrid + flowNavigator.navigateTo( + ChatStep.Profile( + contact = ChatParticipant.TipUser(item.userId, member.userProfile), + origin = ProfileOrigin.GroupMember, + ) + ) + }, + ) + } + // The sample lands after the header; opening the grid in place moves the // requirements card down with it rather than jumping it. AnimatedVisibility( @@ -405,12 +444,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { GroupProfileMenu( expanded = menuOpen, items = groupProfileMenuActions(isMember), + isPrivate = group?.isPrivate == true, anchorHeight = anchorHeight, onDismiss = { menuOpen = false }, onItem = { item -> when (item) { - GroupProfileMenuAction.Encryption -> - navigator.push(AppRoute.Messaging.E2eeGroupInfo) + GroupProfileMenuAction.Encryption -> navigator.push( + if (group?.isPrivate == true) { + AppRoute.Messaging.E2eePrivateGroupInfo + } else { + AppRoute.Messaging.E2eeGroupInfo + } + ) // The picker handles muting and unmuting both; pushed on the // outer navigator, as it is a top-level route. GroupProfileMenuAction.Mute -> state.chatId?.let { chatId -> @@ -460,6 +505,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { private fun GroupProfileMenu( expanded: Boolean, items: List, + isPrivate: Boolean, anchorHeight: androidx.compose.ui.unit.Dp, onDismiss: () -> Unit, onItem: (GroupProfileMenuAction) -> Unit, @@ -477,7 +523,7 @@ private fun GroupProfileMenu( } val tint = if (item.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain DropdownMenuItem( - leadingIcon = { Icon(imageVector = item.icon(), contentDescription = null, tint = tint) }, + leadingIcon = { Icon(imageVector = item.icon(isPrivate), contentDescription = null, tint = tint) }, text = { Text( text = stringResource(item.labelRes()), @@ -497,6 +543,9 @@ private fun GroupProfileMenu( /** How long the requirement amounts wait for their token's name before showing without it. */ private val RuleNameHold = 1500.milliseconds +/** Four rows of portraits; the count beside the title opens the rest. */ +private const val MembersGridLimit = 16 + /** "Private group · 48 members" over "The host approves who joins", behind a lock. */ @Composable private fun PrivateGroupNote(memberCount: Long, modifier: Modifier = Modifier) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt index 0c66447696..07da19da19 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens.profile +import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.SampledChatter @@ -277,6 +278,46 @@ internal sealed interface RequestAge { } } +/** How long ago a member joined, as a private group's Members list words it (node 11210:70). */ +internal sealed interface JoinAge { + data object JustNow : JoinAge + data class Minutes(val count: Long) : JoinAge + data class Hours(val count: Long) : JoinAge + data object Yesterday : JoinAge + data class Days(val count: Long) : JoinAge + data object LastWeek : JoinAge + data class Weeks(val count: Long) : JoinAge + data object LastMonth : JoinAge + data class Months(val count: Long) : JoinAge + + companion object { + fun of(at: Instant, now: Instant): JoinAge { + val elapsed = (now - at).coerceAtLeast(Duration.ZERO) + val days = elapsed.inWholeDays + return when { + elapsed < 1.minutes -> JustNow + elapsed < 1.hours -> Minutes(elapsed.inWholeMinutes) + elapsed < 1.days -> Hours(elapsed.inWholeHours) + days < 2 -> Yesterday + days < 7 -> Days(days) + days < 14 -> LastWeek + days < 30 -> Weeks(days / 7) + days < 60 -> LastMonth + else -> Months(days / 30) + } + } + } +} + +/** + * A private group's roster in the order its grid and list show it: the creator first, then the + * most recently joined. A member with no join time sorts last. + */ +internal fun privateGroupMembers(members: List, creator: ID?): List { + val (host, rest) = members.partition { creator != null && it.userId == creator } + return host + rest.sortedByDescending { it.joinedAt } +} + /** A lobby call in flight from a private group's profile. */ enum class LobbyCall { Request, Cancel } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt new file mode 100644 index 0000000000..31a97d9ec4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt @@ -0,0 +1,176 @@ +package com.flipcash.app.messenger.internal.screens.profile + +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.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +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.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Node 11210:70 — every member of a private group: the creator pinned first with a Host tag, then + * the newest joins. Tap opens the person's profile; there is no remove action. + * + * Reads the flow's shared [ChatViewModel], which holds the roster from GroupRosters, so this list + * and the profile's grid show the same people. + */ +@Composable +internal fun MembersScreen(viewModel: ChatViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + val group = state.subject as? ChatSubject.Group + + // RosterUpdate can skip versions, so the list refetches whenever it comes on screen. + LaunchedEffect(Unit) { viewModel.dispatchEvent(ChatViewModel.Event.MembersOpened) } + + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_members), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + ) { innerPadding -> + val unknownName = stringResource(R.string.title_unnamedUser) + val count = (group?.memberCount ?: state.members.size.toLong()).toInt() + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4), + ) { + item { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x1), + text = pluralStringResource(R.plurals.header_memberCount, count, count), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + items(state.members, key = { it.userId }) { member -> + MemberRow( + member = member, + name = chatterName(member.userProfile.displayName, member.userProfile.username, unknownName), + isHost = member.userId == group?.creator, + age = member.joinedAt?.let { JoinAge.of(it, now) }, + onOpen = { + flowNavigator.navigateTo( + ChatStep.Profile( + contact = ChatParticipant.TipUser(member.userId, member.userProfile), + origin = ProfileOrigin.GroupMember, + ) + ) + }, + ) + } + } + } +} + +@Composable +private fun MemberRow( + member: ChatMember, + name: String, + isHost: Boolean, + age: JoinAge?, + onOpen: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen) + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar(userProfile = member.userProfile, modifier = Modifier.size(40.dp).clip(CircleShape)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isHost) { + Text( + modifier = Modifier + .background(Color.White.copy(alpha = 0.1f), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + text = stringResource(R.string.label_host), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + ) + } else if (age != null) { + Text( + text = joinAgeLabel(age), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun joinAgeLabel(age: JoinAge): String = when (age) { + JoinAge.JustNow -> stringResource(R.string.subtitle_joinedJustNow) + is JoinAge.Minutes -> stringResource(R.string.subtitle_joinedMinutesAgo, age.count) + is JoinAge.Hours -> stringResource(R.string.subtitle_joinedHoursAgo, age.count) + JoinAge.Yesterday -> stringResource(R.string.subtitle_joinedYesterday) + is JoinAge.Days -> stringResource(R.string.subtitle_joinedDaysAgo, age.count) + JoinAge.LastWeek -> stringResource(R.string.subtitle_joinedLastWeek) + is JoinAge.Weeks -> stringResource(R.string.subtitle_joinedWeeksAgo, age.count) + JoinAge.LastMonth -> stringResource(R.string.subtitle_joinedLastMonth) + is JoinAge.Months -> stringResource(R.string.subtitle_joinedMonthsAgo, age.count) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index 53a20ad231..b89a503d7d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -159,6 +159,7 @@ class ChatEventStallTest { featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 88b1f504d3..2f231485a2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -168,6 +168,7 @@ class ChatGroupAnalyticsTest { featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 837d17b214..60c7185bbc 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -172,6 +172,7 @@ class ChatGroupCashLinkTest { featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index 6e7414da6c..0d58626af3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -116,6 +116,7 @@ class ChatMentionPickerTest { featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index a9dba8cd73..df06aa8659 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -121,6 +121,7 @@ class ChatOpenTranscriptTest { featuredGroups = featuredGroups, lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = identifier, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 0804f2cbcd..5963249765 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -159,6 +159,7 @@ class ChatSendFailureAnalyticsTest { featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt index 553a3a267c..b697912ee6 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -120,6 +120,7 @@ class ChatTypingGateTest { featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), lobbyRequests = mockk(relaxed = true), hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupMembersTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupMembersTest.kt new file mode 100644 index 0000000000..f5d6dc6618 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupMembersTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.outlined.LockOpen +import com.flipcash.services.models.chat.ChatMember +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class PrivateGroupMembersTest { + + private val now = Instant.fromEpochSeconds(1_800_000_000) + + private fun member(id: Byte, joinedAgo: kotlin.time.Duration?) = ChatMember( + userId = listOf(id), + userProfile = mockk(relaxed = true), + pointers = emptyList(), + joinedAt = joinedAgo?.let { now - it }, + ) + + @Test + fun `creator is pinned first, then newest joins, unknown join time last`() { + val creator = member(1, 30.days) + val old = member(2, 10.days) + val unknown = member(3, null) + val recent = member(4, 2.hours) + + val ordered = privateGroupMembers(listOf(old, unknown, creator, recent), creator = listOf(1)) + + assertEquals(listOf(1, 4, 2, 3), ordered.map { it.userId.single() }) + } + + @Test + fun `no creator known keeps newest first`() { + val a = member(1, 3.days) + val b = member(2, 1.hours) + + assertEquals(listOf(b, a), privateGroupMembers(listOf(a, b), creator = null)) + } + + @Test + fun `join age buckets match the members list wording`() { + fun age(ago: kotlin.time.Duration) = JoinAge.of(now - ago, now) + + assertEquals(JoinAge.JustNow, age(30.seconds)) + assertEquals(JoinAge.Minutes(5), age(5.minutes)) + assertEquals(JoinAge.Hours(2), age(2.hours)) + assertEquals(JoinAge.Yesterday, age(30.hours)) + assertEquals(JoinAge.Days(3), age(3.days)) + assertEquals(JoinAge.LastWeek, age(8.days)) + assertEquals(JoinAge.Weeks(3), age(21.days)) + assertEquals(JoinAge.LastMonth, age(40.days)) + assertEquals(JoinAge.Months(3), age(95.days)) + } + + @Test + fun `a join time ahead of the clock reads just now`() { + assertEquals(JoinAge.JustNow, JoinAge.of(now + 1.minutes, now)) + } + + @Test + fun `encryption shows a closed lock only for a private group`() { + assertEquals(Icons.Filled.Lock, GroupProfileMenuAction.Encryption.icon(isPrivate = true)) + assertEquals(Icons.Outlined.LockOpen, GroupProfileMenuAction.Encryption.icon(isPrivate = false)) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRosters.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRosters.kt new file mode 100644 index 0000000000..a1de3e4c59 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRosters.kt @@ -0,0 +1,89 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.RosterChange +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** Where the event stream hands a group's roster changes, beside the list-level handling. */ +fun interface RosterUpdateSink { + fun apply(chatId: ChatId, changes: List) + + /** Drops everything held. For logout: the rosters belong to the account. */ + fun clear() = Unit + + object None : RosterUpdateSink { + override fun apply(chatId: ChatId, changes: List) = Unit + } +} + +/** + * Full rosters of the groups whose members are shown in full (a private group's Members grid and + * list), most recently joined first. + * + * [load] reads a roster to the end; the event stream's RosterUpdates are applied on top through + * [apply]. A join is kept per member by [ChatMember.version], the greater wins, as [RosterPage] + * documents for merging pages. + */ +@Singleton +class GroupRosters @Inject constructor( + private val chatController: ChatController, +) : RosterUpdateSink { + + private val rosters = MutableStateFlow>>(emptyMap()) + + /** Each loaded roster, most recently joined first. A chat never loaded is absent. */ + val state: StateFlow>> = rosters.asStateFlow() + + /** Fetches [chatId]'s whole roster, every page, and replaces what was held for it. */ + suspend fun load(chatId: ChatId): Result> = runCatching { + val members = mutableListOf() + var options = QueryOptions() + do { + val page = chatController.getRoster(chatId, options).getOrThrow() + members += page.members + options = options.copy(token = page.pagingToken) + } while (page.hasMore && page.pagingToken != null) + newestFirst(members.fold(emptyList(), ::merge)) + }.onSuccess { members -> + rosters.update { it + (chatId to members) } + } + + /** Applies changes only to a roster already loaded: a partial one would read as complete. */ + override fun apply(chatId: ChatId, changes: List) { + rosters.update { all -> + val current = all[chatId] ?: return@update all + val next = changes.fold(current) { members, change -> + when (change) { + is RosterChange.MemberJoined -> merge(members, change.member) + is RosterChange.MemberLeft -> members.filterNot { it.userId == change.userId } + is RosterChange.MembershipChanged -> members + } + } + all + (chatId to newestFirst(next)) + } + } + + override fun clear() { + rosters.value = emptyMap() + } + + private fun merge(members: List, member: ChatMember): List { + val held = members.firstOrNull { it.userId == member.userId } + return when { + held == null -> members + member + member.version > held.version -> members.map { if (it.userId == member.userId) member else it } + else -> members + } + } + + private fun newestFirst(members: List) = + members.sortedByDescending { it.joinedAt } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 97e079030a..2bbbb0ec8a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -6,6 +6,8 @@ import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.LobbyRequestStore import com.flipcash.shared.chat.LobbyUpdateSink +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.chat.RosterUpdateSink import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption @@ -58,6 +60,11 @@ abstract class ChatModule { impl: HostLobbies ): LobbyUpdateSink + @Binds + abstract fun bindRosterUpdateSink( + impl: GroupRosters + ): RosterUpdateSink + @Binds @Singleton abstract fun bindChatMediaSending( 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 91cbde2e18..50d0edce1c 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 @@ -12,6 +12,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.LobbyUpdateSink +import com.flipcash.shared.chat.RosterUpdateSink import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatState @@ -103,6 +104,7 @@ class RealChatCoordinator @Inject constructor( private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, private val mediaSender: ChatMediaSending = ChatMediaSending.None, private val lobbyUpdates: LobbyUpdateSink = LobbyUpdateSink.None, + private val rosterUpdates: RosterUpdateSink = RosterUpdateSink.None, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -221,8 +223,10 @@ class RealChatCoordinator @Inject constructor( syncFeeds(fresh = true) is EventStreamDelegate.Event.LoadMessages -> messagingDelegate.loadMessages(event.chatId) - is EventStreamDelegate.Event.RosterChanged -> + is EventStreamDelegate.Event.RosterChanged -> { groupFeedDelegate.applyRosterChanges(event.chatId, event.changes) + rosterUpdates.apply(event.chatId, event.changes) + } is EventStreamDelegate.Event.LobbyChanged -> lobbyUpdates.apply(event.chatId, event.updates) } @@ -423,6 +427,7 @@ class RealChatCoordinator @Inject constructor( eventStreamDelegate.clearAll() reactionsDelegate.clearAll() lobbyUpdates.clear() + rosterUpdates.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/GroupRostersTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRostersTest.kt new file mode 100644 index 0000000000..8f91c60beb --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRostersTest.kt @@ -0,0 +1,112 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.RosterChange +import com.flipcash.services.models.chat.RosterPage +import com.flipcash.services.models.chat.RosterSummary +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class GroupRostersTest { + + private val chatController = mockk() + private val rosters = GroupRosters(chatController) + + private val chat = ChatId("aa") + private val summary = RosterSummary(memberCount = 2, version = 5) + + private fun member(id: Byte, joinedAt: Long, version: Long = joinedAt, name: String = "user $id") = + ChatMember( + userId = listOf(id), + userProfile = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + userId = listOf(id), + ), + pointers = emptyList(), + joinedAt = Instant.fromEpochSeconds(joinedAt), + version = version, + ) + + private val host = member(1, 100) + private val joiner = member(2, 200) + + private fun ids() = rosters.state.value[chat]?.map { it.userId } + + private suspend fun loaded(vararg members: ChatMember) { + coEvery { chatController.getRoster(chat, any()) } returns + Result.success(RosterPage(members.toList(), summary, null, hasMore = false)) + rosters.load(chat).getOrThrow() + } + + @Test + fun `load reads every page and orders most recently joined first`() = runTest { + val token: PagingToken = listOf(7) + coEvery { chatController.getRoster(chat, match { it.token == null }) } returns + Result.success(RosterPage(listOf(host), summary, token, hasMore = true)) + coEvery { chatController.getRoster(chat, match { it.token == token }) } returns + Result.success(RosterPage(listOf(joiner), summary, null, hasMore = false)) + + rosters.load(chat).getOrThrow() + + assertEquals(listOf(listOf(2), listOf(1)), ids()) + } + + @Test + fun `a member on two pages keeps the greater version`() = runTest { + val renamed = member(2, 200, version = 300, name = "renamed") + coEvery { chatController.getRoster(chat, any()) } returns + Result.success(RosterPage(listOf(renamed, joiner), summary, null, hasMore = false)) + + rosters.load(chat).getOrThrow() + + assertEquals("renamed", rosters.state.value[chat]?.single()?.userProfile?.displayName) + } + + @Test + fun `changes before a load are ignored`() { + rosters.apply(chat, listOf(RosterChange.MemberJoined(joiner, null, summary))) + + assertNull(ids()) + } + + @Test + fun `joins and leaves apply to a loaded roster`() = runTest { + loaded(host) + + rosters.apply(chat, listOf(RosterChange.MemberJoined(joiner, null, summary))) + assertEquals(listOf(listOf(2), listOf(1)), ids()) + + rosters.apply(chat, listOf(RosterChange.MemberLeft(listOf(2), summary))) + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `an older join does not replace a newer one`() = runTest { + loaded(member(2, 200, version = 9, name = "current")) + + rosters.apply(chat, listOf(RosterChange.MemberJoined(member(2, 200, version = 3, name = "stale"), null, summary))) + + assertEquals("current", rosters.state.value[chat]?.single()?.userProfile?.displayName) + } + + @Test + fun `clear drops every roster`() = runTest { + loaded(host) + + rosters.clear() + + assertNull(ids()) + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt index 46eb7b0023..74b889aa96 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt @@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.WavingHand import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -68,6 +70,9 @@ fun chatterName(displayName: String?, username: String?, fallback: String): Stri * Who has been talking in a public group: a "Chatting" heading over portraits four to a row, the * host badged. Draws nothing for an empty list, heading included. * + * A private group's Members grid passes its own [title] and a [count] that opens the full list + * through [onShowAll]. + * * Rows are built by hand rather than as a lazy grid so it can sit inside the profile's own scroll. * A short last row leaves its empty cells blank to keep the columns aligned. The order is the * caller's; the grid does not sort. @@ -77,6 +82,9 @@ fun GroupChattingGrid( chatters: List, onOpen: (ChattingGridItem) -> Unit, modifier: Modifier = Modifier, + title: String = stringResource(R.string.title_chatting), + count: String? = null, + onShowAll: () -> Unit = {}, ) { if (chatters.isEmpty()) return @@ -84,11 +92,35 @@ fun GroupChattingGrid( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(SectionSpacing), ) { - Text( - text = stringResource(R.string.title_chatting), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Spacer(modifier = Modifier.weight(1f)) + if (count != null) { + Row( + modifier = Modifier.clickable(role = Role.Button, onClick = onShowAll), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = count, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + } + } Column(verticalArrangement = Arrangement.spacedBy(RowSpacing)) { chatters.chunked(Columns).forEach { row -> Row(horizontalArrangement = Arrangement.spacedBy(ColumnSpacing)) { From 18757286e18c400625f3d86e053981d5a90e7ace Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:28:12 -0400 Subject: [PATCH 09/25] feat(chat): list reactions as unencrypted in the private group E2EE sheet Matches node 11210:80: reactions in a private group are not encrypted, as in a DM. --- .../app/messenger/internal/screens/E2eeLearnMoreSheet.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt index 96da76ef7d..a3d4f3b7a0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt @@ -43,9 +43,6 @@ internal enum class E2eeSheetKind { Dm, Group, PrivateGroup } * What end-to-end encryption does and doesn't cover, opened from the profile footer. One layout * for every kind, with the DM's, the public group's and the private group's own words (nodes * 10416:1533, 10557:1412, 11210:71). - * - * The private group's frame also lists "Reactions, and who sent them" as not encrypted. Nobody - * has confirmed reactions in a private group behave as in a DM, so that row is left out. */ @Composable internal fun E2eeLearnMoreSheet( @@ -78,6 +75,7 @@ internal fun E2eeLearnMoreSheet( E2eeSheetKind.PrivateGroup -> listOf( R.string.item_e2eeSheet_groupDetails, R.string.item_e2eeSheet_groupMembers, + R.string.item_e2eeSheet_reactions, R.string.item_e2eeSheet_payments, ) } From 233442575f0d5a82014f1f2549f3212aaeabfdc7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:29:27 -0400 Subject: [PATCH 10/25] fix(groups): say private groups when a private create is denied The denial sheet said "public groups" whatever form was open. --- .../core/src/main/res/values/strings.xml | 1 + .../tipping/internal/CreateGroupViewModel.kt | 5 ++++- .../internal/CreateGroupViewModelErrorTest.kt | 22 +++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index d9b19c6759..f9908af1ca 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1210,6 +1210,7 @@ Try a different balance requirement Can\'t Create This Group Your account isn\'t able to create public groups + Your account isn\'t able to create private groups Something Went Wrong diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index be47457f2b..c33cf2c271 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -577,7 +577,10 @@ internal class CreateGroupViewModel @AssistedInject constructor( is StartChatError.Denied -> BottomBarManager.showAlert( title = resources.getString(R.string.error_title_groupCreateDenied), - message = resources.getString(R.string.error_description_groupCreateDenied), + message = resources.getString( + if (stateFlow.value.isPrivate) R.string.error_description_privateGroupCreateDenied + else R.string.error_description_groupCreateDenied + ), ) // The blob was accepted by storage and refused by StartChat, so the picture is what has diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt index e2d9515092..9a7903dcab 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt @@ -96,8 +96,8 @@ class CreateGroupViewModelErrorTest { BottomBarManager.clear() } - private fun createViewModel() = CreateGroupViewModel( - isPrivate = false, + private fun createViewModel(isPrivate: Boolean = false) = CreateGroupViewModel( + isPrivate = isPrivate, exchange = exchange, tokenCoordinator = tokenCoordinator, dispatchers = dispatchers, @@ -262,6 +262,24 @@ class CreateGroupViewModelErrorTest { assertTrue(titles().contains("error_title_groupCreateDenied")) } + @Test + fun `a denied private create says private groups`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel(isPrivate = true) + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + + failing(StartChatError.Denied()) + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertTrue( + BottomBarManager.messages.value.any { + it.subtitle == "error_description_privateGroupCreateDenied" + } + ) + } + @Test fun `an unrecognised failure falls back to the generic refusal`() = runTest(mainCoroutineRule.dispatcher) { From fea658026ea526cf32348f1635f61b251ef0a002 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:57:16 -0400 Subject: [PATCH 11/25] feat(groups): members read and send in private groups An admitted member's key envelope is wrapped by the creator, and GroupKeyEnvelopes only ever unwrapped against the viewer's own key, so a member could not open the group key, read the transcript, or send. - GroupKeyEnvelopes follows GetKeyEnvelope.wrapped_by: self (or unset) opens with the own key; the creator opens with the creator's key, then self-wraps and stores it with SetKeyEnvelope (ALREADY_SET refetches); any other wrapper is rejected. NO_ENVELOPE regenerates the key only for the creator. - GroupContentCrypto seals and opens scheme 2 messages and blobs under the group key, with the sender's user id in the AAD. - E2eePolicy encrypts every private group. Send, photo sealing, the incoming opener and the photo loader branch on the chat's is_private before the single-peer DM path. - ChatPreviewsReadyTest gets the lobby and roster constructor args it missed when it arrived from code/cash. --- .../internal/ChatPreviewsReadyTest.kt | 3 + .../chat/internal/OutgoingEncryption.kt | 25 +++- .../shared/chat/media/ChatPhotoLoader.kt | 39 +++-- .../chat/MessagingPushDecryptionTest.kt | 2 +- .../chat/MessagingSendEncryptionTest.kt | 2 + .../shared/chat/media/ChatPhotoLoaderTest.kt | 4 +- .../sources/ChatMetadataDataSource.kt | 5 + .../sources/IncomingMessageOpener.kt | 21 ++- .../sources/IncomingMessageOpenerTest.kt | 49 +++++- .../flipcash/services/chat/ChatKeySource.kt | 5 + .../com/flipcash/services/chat/E2eePolicy.kt | 4 +- .../services/chat/GroupContentCrypto.kt | 140 ++++++++++++++++++ .../services/chat/GroupKeyEnvelopes.kt | 89 +++++++++-- .../services/inject/ChatCryptoModule.kt | 5 + .../services/chat/GroupKeyEnvelopesTest.kt | 104 +++++++++++++ 15 files changed, 453 insertions(+), 44 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt index 4514def554..64e81fc788 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt @@ -104,6 +104,9 @@ class ChatPreviewsReadyTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = featuredGroups, + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, linkCardMemory = memory, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt index c6494445f7..30724c484e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt @@ -7,6 +7,7 @@ import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.carriesMedia import com.flipcash.services.chat.checkBlobSealing import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.BlobId @@ -101,6 +102,7 @@ internal class DmOutgoingEncryption @Inject constructor( private val chatController: ChatController, private val metadataDataSource: ChatMetadataDataSource, private val memberDataSource: ChatMemberDataSource, + private val groupCrypto: GroupContentCrypto, ) : OutgoingEncryption { override suspend fun prepare( @@ -121,15 +123,19 @@ internal class DmOutgoingEncryption @Inject constructor( val selfId = userManager.accountId ?: return Result.failure(IllegalStateException("No account to encrypt from")) - val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId - ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) - // A DM message is one Text, one photo, or one Reply around either. Anything else can't be - // sealed, and sending it in plaintext would break the chat's promise. + // A sealed message is one Text, one photo, or one Reply around either. Anything else can't + // be sealed, and sending it in plaintext would break the chat's promise. val single = content.singleOrNull() ?: return Result.failure(IllegalArgumentException("Can't encrypt ${content.size} content items")) - return crypto.seal(chatId, peerId, single) - .map { sealed -> Outgoing(plaintext = content, wire = listOf(sealed), isSealed = true) } + val sealed = if (chat.isPrivate) { + groupCrypto.seal(chatId, selfId, single) + } else { + val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId + ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) + crypto.seal(chatId, peerId, single) + } + return sealed.map { Outgoing(plaintext = content, wire = listOf(it), isSealed = true) } } override suspend fun blobSealer(chatId: ChatId): Result { @@ -139,6 +145,13 @@ internal class DmOutgoingEncryption @Inject constructor( val selfId = userManager.accountId ?: return Result.failure(IllegalStateException("No account to encrypt from")) + if (chat.isPrivate) { + return Result.success( + BlobSealer(chatId) { blobId, plaintext -> + groupCrypto.sealBlob(chatId, selfId, blobId.bytes, plaintext).getOrThrow() + } + ) + } val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) return Result.success( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt index ed3b9deae4..85abcb3162 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt @@ -1,8 +1,10 @@ package com.flipcash.shared.chat.media import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.OpenedBlob import com.flipcash.services.controllers.BlobStorageController import com.flipcash.services.models.chat.BlobAccessContext @@ -64,6 +66,8 @@ class ChatPhotoLoader internal constructor( private val crypto: ChatContentCrypto, private val userManager: UserManager, private val members: ChatMemberDataSource, + private val groupCrypto: GroupContentCrypto, + private val metadata: ChatMetadataDataSource, private val now: () -> kotlin.time.Instant, ) { @Inject @@ -72,7 +76,9 @@ class ChatPhotoLoader internal constructor( crypto: ChatContentCrypto, userManager: UserManager, members: ChatMemberDataSource, - ) : this(blobStorage, crypto, userManager, members, { Clock.System.now() }) + groupCrypto: GroupContentCrypto, + metadata: ChatMetadataDataSource, + ) : this(blobStorage, crypto, userManager, members, groupCrypto, metadata, { Clock.System.now() }) /** * A URL to download [photo] from: the stored one while it is usable, else a freshly minted one. @@ -97,23 +103,32 @@ class ChatPhotoLoader internal constructor( */ suspend fun open(photo: ChatPhoto, downloaded: ByteArray): ByteArray { if (!photo.sealed) return downloaded - val selfId = userManager.accountId ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) - val peerId = members.getMembersForChat(photo.chatId).firstOrNull { it.userId != selfId }?.userId - ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) val expected = photo.rendition.blob?.sizeBytes ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.Length) - return when ( - val opened = crypto.openBlob( + if (metadata.isPrivate(photo.chatId)) { + return groupCrypto.openBlob( chatId = photo.chatId, - selfId = selfId, - peerId = peerId, senderId = photo.senderId, blobId = photo.rendition.blobId.bytes, expectedSize = expected, sealed = downloaded, - ) - ) { - is OpenedBlob.Plaintext -> opened.bytes - is OpenedBlob.Failed -> throw ChatPhotoUnavailable.Open(opened.reason) + ).bytesOrThrow() } + val selfId = userManager.accountId ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) + val peerId = members.getMembersForChat(photo.chatId).firstOrNull { it.userId != selfId }?.userId + ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) + return crypto.openBlob( + chatId = photo.chatId, + selfId = selfId, + peerId = peerId, + senderId = photo.senderId, + blobId = photo.rendition.blobId.bytes, + expectedSize = expected, + sealed = downloaded, + ).bytesOrThrow() + } + + private fun OpenedBlob.bytesOrThrow(): ByteArray = when (this) { + is OpenedBlob.Plaintext -> bytes + is OpenedBlob.Failed -> throw ChatPhotoUnavailable.Open(reason) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt index d71a63ea67..a57de351b7 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -60,7 +60,7 @@ class MessagingPushDecryptionTest { stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), senderResolver = mockk(relaxed = true), - incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)), + incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide), mockk()), ) private suspend fun sealedFromPeer(content: MessageContent) = diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 4640101996..74239c1bcb 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -130,6 +130,7 @@ class MessagingSendEncryptionTest { chatController = chatController, metadataDataSource = metadata, memberDataSource = members, + groupCrypto = mockk(), ), ) } @@ -209,6 +210,7 @@ class MessagingSendEncryptionTest { chatController = chatController, metadataDataSource = metadata, memberDataSource = members, + groupCrypto = mockk(), ) @Test diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt index b32b58005e..8c0d95f1d0 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.media import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.OpenedBlob @@ -41,7 +42,8 @@ class ChatPhotoLoaderTest { ChatMember(it, UserProfile(displayName = "n", socialAccounts = emptyList(), phoneNumber = null, email = null), emptyList()) } } - private val loader = ChatPhotoLoader(storage, crypto, user, members) { now } + private val metadata = mockk { coEvery { isPrivate(any()) } returns false } + private val loader = ChatPhotoLoader(storage, crypto, user, members, mockk(), metadata) { now } private fun metadata(url: String, expiresAt: Instant? = null) = BlobMetadata( mimeType = "image/jpeg", sizeBytes = 3, downloadUrl = url, expiresAtMillis = expiresAt?.toEpochMilliseconds(), diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index e6a2bfaa87..f9ecc49b47 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -12,6 +12,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -157,6 +158,10 @@ class ChatMetadataDataSource @Inject constructor( return ChatType.entries.firstOrNull { it.name == stored } ?: ChatType.UNKNOWN } + /** Whether [chatId] is a stored private group, whose content is sealed under the chat key. */ + suspend fun isPrivate(chatId: ChatId): Boolean = + observeById(chatId).first()?.isPrivate == true + /** [chatId]'s group name, or null when the chat is a DM or is not stored on this device. */ suspend fun getTitle(chatId: ChatId): String? = db?.chatMetadataDao()?.getTitle(mapper.chatIdHex(chatId)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt index da18daaefc..57a649594c 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt @@ -1,6 +1,7 @@ package com.flipcash.app.persistence.sources import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.chat.OpenedContent import com.flipcash.services.models.chat.ChatId @@ -16,6 +17,7 @@ import javax.inject.Inject */ class IncomingMessageOpener @Inject constructor( private val crypto: ChatContentCrypto, + private val groupCrypto: GroupContentCrypto, ) { /** * [messages] with each encrypted one opened and its [ChatMessage.encryption] set. A message @@ -23,7 +25,8 @@ class IncomingMessageOpener @Inject constructor( * opened again. * * [peerId] is the other member of the DM when it's stored; a message from the viewer can't be - * opened without it, and is left [MessageEncryption.KeyPending] to be opened once it is. + * opened without it, and is left [MessageEncryption.KeyPending] to be opened once it is. A + * private group's message is sealed under the chat key and needs no peer. */ suspend fun open( chatId: ChatId, @@ -56,6 +59,9 @@ class IncomingMessageOpener @Inject constructor( message: ChatMessage, sealed: MessageContent.Encrypted, ): ChatMessage { + if (sealed.scheme == GroupContentCrypto.SCHEME_CHAT_KEY_XCHACHA20POLY1305) { + return message.withOpened(sealed, groupCrypto.open(chatId, message.senderId, sealed)) + } val peer = when (val sender = message.senderId) { null -> null selfId -> peerId @@ -65,17 +71,20 @@ class IncomingMessageOpener @Inject constructor( return message.copy(content = listOf(sealed), encryption = MessageEncryption.KeyPending) } - return when (val opened = crypto.open(chatId, selfId, peer, message.senderId, sealed)) { - is OpenedContent.Plaintext -> message.copy( + return message.withOpened(sealed, crypto.open(chatId, selfId, peer, message.senderId, sealed)) + } + + private fun ChatMessage.withOpened(sealed: MessageContent.Encrypted, opened: OpenedContent): ChatMessage = + when (opened) { + is OpenedContent.Plaintext -> copy( content = listOf(opened.content), encryption = MessageEncryption.Decrypted(sealed), ) - OpenedContent.KeyPending -> message.copy(encryption = MessageEncryption.KeyPending) - is OpenedContent.Undecryptable -> message.copy( + OpenedContent.KeyPending -> copy(encryption = MessageEncryption.KeyPending) + is OpenedContent.Undecryptable -> copy( encryption = MessageEncryption.Undecryptable(opened.reason), ) } - } /** * The ciphertext of a message as the server sent it. A message that already carries an diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt index 34c53b06c8..87e4ce3d36 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt @@ -3,6 +3,9 @@ package com.flipcash.app.persistence.sources import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.UntrustedEnvelope import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.chat.UndecryptableReason import com.flipcash.services.models.chat.ChatId @@ -35,7 +38,10 @@ class IncomingMessageOpenerTest { } private val selfSide = Keys(selfKeys, peerKeys.publicKey) - private val opener = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)) + private val groupKey = ByteArray(32) { 5 } + private var groupKeyResult: Result = Result.success(groupKey) + private val group = GroupContentCrypto(FakeChatCipher, GroupKeySource { groupKeyResult }) + private val opener = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide), group) private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) private val mine = ChatContentCrypto(FakeChatCipher, Keys(selfKeys, peerKeys.publicKey)) @@ -106,7 +112,7 @@ class IncomingMessageOpenerTest { @Test fun `an unknown scheme is unsupported`() = runTest { - val opened = open(message(fromPeer("hi").copy(scheme = 2))).single() + val opened = open(message(fromPeer("hi").copy(scheme = 3))).single() assertEquals(MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), opened.encryption) } @@ -152,4 +158,43 @@ class IncomingMessageOpenerTest { assertEquals(listOf(MessageContent.Text("hi")), reopened.content) assertEquals(MessageEncryption.Decrypted(sealed), reopened.encryption) } + + @Test + fun `a private group message opens under the chat key with no peer stored`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + + val opened = open(message(sealed), peerId = null).single() + + assertEquals(listOf(MessageContent.Text("group")), opened.content) + assertEquals(MessageEncryption.Decrypted(sealed), opened.encryption) + } + + @Test + fun `a private group message waits when the chat key can't be fetched`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + groupKeyResult = Result.failure(IOException("offline")) + + assertEquals(MessageEncryption.KeyPending, open(message(sealed)).single().encryption) + } + + @Test + fun `a private group message under an untrusted envelope is undecryptable`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + groupKeyResult = Result.failure(UntrustedEnvelope("wrapped by a stranger")) + + assertEquals( + MessageEncryption.Undecryptable(UndecryptableReason.Authentication), + open(message(sealed)).single().encryption, + ) + } + + @Test + fun `a private group message is bound to its sender`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + + assertEquals( + MessageEncryption.Undecryptable(UndecryptableReason.Authentication), + open(message(sealed, senderId = self)).single().encryption, + ) + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt index 86bb6749b6..8530815f65 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt @@ -11,6 +11,9 @@ interface ChatKeySource { /** The viewer's account key pair, or null when no account is loaded. */ fun ownKeyPair(): KeyPair? + /** The viewer's Flipcash user id, or null when no account is loaded. */ + fun ownUserId(): ID? = null + /** The Ed25519 public key [userId] registered their account with. */ suspend fun peerPublicKey(userId: ID): Result } @@ -31,6 +34,8 @@ internal class DefaultChatKeySource @Inject constructor( KeyPair(publicKey = keyPair.publicKeyBytes, privateKey = keyPair.privateKeyBytes) } + override fun ownUserId(): ID? = userManager.accountId + override suspend fun peerPublicKey(userId: ID): Result = resolver.resolve(userId).map { it.byteArray } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt index 6ed1f8f9af..373d8e63a6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt @@ -11,11 +11,13 @@ import javax.inject.Inject * * This is the only reader of [ChatMetadata.useE2ee] in the app. It maps a chat onto * [ChatEncryptionPolicy], which both apps share, so the two platforms can't disagree on when a DM - * is encrypted. The @flipcash exemption lives there. + * is encrypted. The @flipcash exemption lives there. A private group is always encrypted. */ class E2eePolicy @Inject constructor() { fun shouldEncrypt(chat: ChatMetadata): Boolean { + // A private group is always encrypted, under its chat key; the server refuses plaintext. + if (chat.isPrivate) return true // CONTACT_DM and TIP_DM, matching the server's IsDmChatType. val isDm = chat.type == ChatType.CONTACT_DM || chat.type == ChatType.TIP_DM // Every member is checked rather than picking out the peer, so the answer doesn't depend diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt new file mode 100644 index 0000000000..e5bc3436f6 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt @@ -0,0 +1,140 @@ +package com.flipcash.services.chat + +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.toMessageContent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.opencode.model.core.ID +import com.google.protobuf.InvalidProtocolBufferException +import javax.inject.Inject +import javax.inject.Singleton + +/** The 32-byte chat key of a private group the viewer belongs to. */ +fun interface GroupKeySource { + suspend fun key(chatId: ChatId): Result +} + +/** + * Seals and opens private-group content as `messaging.v1.EncryptedContent` scheme + * `CHAT_KEY_XCHACHA20POLY1305`, under the chat key every member holds. The DM counterpart is + * [ChatContentCrypto]. + * + * A key that could not be fetched leaves a message [OpenedContent.KeyPending]; an envelope that + * cannot be trusted, or a payload that fails under the key held, is undecryptable. + */ +@Singleton +class GroupContentCrypto @Inject constructor( + private val cipher: ChatCipher, + private val keys: GroupKeySource, +) { + /** Encrypts [content] that [senderId], the viewer, sends in [chatId]. */ + suspend fun seal(chatId: ChatId, senderId: ID, content: MessageContent): Result { + val outbound = content.withoutDownloadUrls() + if (!outbound.isSealable()) { + return Result.failure(IllegalArgumentException("Cannot encrypt ${content::class.simpleName}")) + } + val groupKey = keys.key(chatId).getOrElse { return Result.failure(it) } + return runCatching { + val payload = cipher.encryptGroup( + content = outbound.asContent().toByteArray(), + groupKey = groupKey, + chatId = chatId.bytes, + senderId = senderId.toByteArray(), + ) + MessageContent.Encrypted( + scheme = SCHEME_CHAT_KEY_XCHACHA20POLY1305, + nonce = payload.nonce, + ciphertext = payload.ciphertext, + ) + } + } + + /** Decrypts [encrypted], a message [senderId] sent in [chatId]. */ + suspend fun open(chatId: ChatId, senderId: ID?, encrypted: MessageContent.Encrypted): OpenedContent { + if (encrypted.scheme != SCHEME_CHAT_KEY_XCHACHA20POLY1305) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + senderId ?: return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + val groupKey = keys.key(chatId).getOrElse { cause -> + return if (cause is UntrustedEnvelope) { + OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } else { + OpenedContent.KeyPending + } + } + + val plaintext = try { + cipher.decryptGroup( + payload = EncryptedPayload(nonce = encrypted.nonce, ciphertext = encrypted.ciphertext), + groupKey = groupKey, + chatId = chatId.bytes, + senderId = senderId.toByteArray(), + ) + } catch (_: ChatCipherException) { + return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } + + val content = try { + MessagingModel.Content.parseFrom(plaintext) + } catch (_: InvalidProtocolBufferException) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + return if (content.isRenderable()) { + OpenedContent.Plaintext(content.toMessageContent()) + } else { + OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + } + + /** Seals [plaintext], the stripped JPEG of photo [blobId] that the viewer [senderId] uploads. */ + suspend fun sealBlob(chatId: ChatId, senderId: ID, blobId: ByteArray, plaintext: ByteArray): Result { + val groupKey = keys.key(chatId).getOrElse { return Result.failure(it) } + return runCatching { + cipher.encryptGroupBlob( + image = plaintext, + groupKey = groupKey, + chatId = chatId.bytes, + blobId = blobId, + senderId = senderId.toByteArray(), + ) + } + } + + /** Opens [sealed], blob [blobId] that [senderId] uploaded, and checks it is [expectedSize] bytes. */ + suspend fun openBlob( + chatId: ChatId, + senderId: ID?, + blobId: ByteArray, + expectedSize: Long, + sealed: ByteArray, + ): OpenedBlob { + senderId ?: return OpenedBlob.Failed(BlobOpenFailure.Authentication) + val groupKey = keys.key(chatId).getOrElse { cause -> + return OpenedBlob.Failed( + if (cause is UntrustedEnvelope) BlobOpenFailure.Authentication else BlobOpenFailure.KeyPending, + ) + } + val plaintext = try { + cipher.decryptGroupBlob( + blob = sealed, + groupKey = groupKey, + chatId = chatId.bytes, + blobId = blobId, + senderId = senderId.toByteArray(), + ) + } catch (_: ChatCipherException) { + return OpenedBlob.Failed(BlobOpenFailure.Authentication) + } + if (plaintext.size.toLong() != expectedSize) return OpenedBlob.Failed(BlobOpenFailure.Length) + return OpenedBlob.Plaintext(plaintext) + } + + companion object { + /** `EncryptedContent.Scheme.CHAT_KEY_XCHACHA20POLY1305`. */ + const val SCHEME_CHAT_KEY_XCHACHA20POLY1305 = 2 + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt index 4ee1a73102..5cb5fb3b10 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -1,12 +1,16 @@ package com.flipcash.services.chat import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.KeyEnvelope import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -23,11 +27,12 @@ class GroupKeyEnvelopes @Inject constructor( private val cipher: ChatCipher, private val keys: ChatKeySource, private val chatController: ChatController, -) { +) : GroupKeySource { // Own public key in the key so a different account on this device never reuses a group key. private data class CacheKey(val chatId: ID, val ownPk: ID) private val groupKeys = ConcurrentHashMap() + private val locks = ConcurrentHashMap() /** * Generates [chatId]'s chat key and stores the creator's own envelope: the second step of @@ -54,30 +59,81 @@ class GroupKeyEnvelopes @Inject constructor( suspend fun wrapFor(chatId: ChatId, recipientPk: ByteArray): Result { val own = keys.ownKeyPair() ?: return Result.failure(IllegalStateException("No account key pair")) - val groupKey = groupKey(chatId).getOrElse { return Result.failure(it) } + val groupKey = key(chatId).getOrElse { return Result.failure(it) } return runCatching { cipher.wrapGroupKey(own, recipientPk, chatId.bytes, groupKey).toKeyEnvelope() } } /** - * The viewer's copy of [chatId]'s chat key, opened from their stored envelope. Only a - * self-wrapped envelope is opened here; one the creator wrapped for a member is the member's - * path, which needs the creator's key. + * The viewer's copy of [chatId]'s chat key, fetched once per chat per launch and kept in memory. + * + * A self-wrapped envelope opens under the viewer's own key. One the creator wrapped when + * admitting the viewer opens under the creator's key, which comes from the Resolver for + * `Metadata.creator`; the member then wraps the key for itself and stores that, so its other + * devices depend only on its own key. An envelope wrapped by anyone else is refused, and so is + * one that fails to open: the caller must not send in the chat without this key. + * + * `NO_ENVELOPE` means a creator whose setup stopped before [storeNewKey] landed; only the + * creator regenerates the key there. */ - private suspend fun groupKey(chatId: ChatId): Result { + override suspend fun key(chatId: ChatId): Result { val own = keys.ownKeyPair() ?: return Result.failure(IllegalStateException("No account key pair")) val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList()) groupKeys[cacheKey]?.let { return Result.success(it) } - val stored = chatController.getKeyEnvelope(chatId).getOrElse { return Result.failure(it) } - return runCatching { - cipher.unwrapGroupKey( - ownKeyPair = own, - wrapperPk = own.publicKey, - chatId = chatId.bytes, - envelope = EncryptedPayload(stored.envelope.nonce, stored.envelope.ciphertext), - ) - }.onSuccess { groupKeys[cacheKey] = it } + return locks.getOrPut(cacheKey) { Mutex() }.withLock { + groupKeys[cacheKey]?.let { return@withLock Result.success(it) } + fetchKey(chatId, own) + .onSuccess { groupKeys[cacheKey] = it } + } + } + + private suspend fun fetchKey(chatId: ChatId, own: KeyPair, retried: Boolean = false): Result { + val stored = chatController.getKeyEnvelope(chatId).getOrElse { error -> + if (error is GetKeyEnvelopeError.NoEnvelope && !retried && viewerIsCreator(chatId)) { + // storeNewKey caches the key it stored; ALREADY_SET leaves nothing cached, so read that one. + return storeNewKey(chatId).mapCatching { + groupKeys[CacheKey(chatId.bytes.toList(), own.publicKey.toList())] + ?: fetchKey(chatId, own, retried = true).getOrThrow() + } + } + return Result.failure(error) + } + if (stored.envelope.scheme != KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305) { + return Result.failure(UntrustedEnvelope("Unsupported envelope scheme ${stored.envelope.scheme}")) + } + val payload = EncryptedPayload(stored.envelope.nonce, stored.envelope.ciphertext) + val wrapper = stored.wrappedBy + + if (wrapper == null || wrapper == keys.ownUserId()) { + return runCatching { cipher.unwrapGroupKey(own, own.publicKey, chatId.bytes, payload) } + .recoverCatching { throw UntrustedEnvelope("Self-wrapped envelope did not open", it) } + } + + val creator = chatController.getChat(chatId).getOrElse { return Result.failure(it) }.creator + if (creator == null || wrapper != creator) { + return Result.failure(UntrustedEnvelope("Envelope wrapped by someone other than the creator")) + } + val creatorPk = keys.peerPublicKey(creator).getOrElse { return Result.failure(it) } + val groupKey = runCatching { cipher.unwrapGroupKey(own, creatorPk, chatId.bytes, payload) } + .getOrElse { return Result.failure(UntrustedEnvelope("Creator-wrapped envelope did not open", it)) } + + val selfWrapped = runCatching { + cipher.wrapGroupKey(own, own.publicKey, chatId.bytes, groupKey).toKeyEnvelope() + }.getOrElse { return Result.failure(it) } + return chatController.setKeyEnvelope(chatId, selfWrapped).fold( + onSuccess = { Result.success(groupKey) }, + onFailure = { error -> + // Another device re-wrapped first; its envelope stands, so read that one. + if (error is SetKeyEnvelopeError.AlreadySet && !retried) fetchKey(chatId, own, retried = true) + else Result.failure(error) + }, + ) + } + + private suspend fun viewerIsCreator(chatId: ChatId): Boolean { + val self = keys.ownUserId() ?: return false + return chatController.getChat(chatId).getOrNull()?.creator == self } private fun EncryptedPayload.toKeyEnvelope() = KeyEnvelope( @@ -86,3 +142,6 @@ class GroupKeyEnvelopes @Inject constructor( ciphertext = ciphertext, ) } + +/** A key envelope this client will not trust: wrong wrapper, unknown scheme, or it failed to open. */ +class UntrustedEnvelope(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt index 7c3317d7f4..76c4cc2d78 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt @@ -2,6 +2,8 @@ package com.flipcash.services.inject import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.DefaultChatKeySource +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.chat.GroupKeySource import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.DefaultChatCipher import dagger.Binds @@ -17,6 +19,9 @@ internal abstract class ChatCryptoModule { @Binds abstract fun bindChatKeySource(source: DefaultChatKeySource): ChatKeySource + @Binds + abstract fun bindGroupKeySource(source: GroupKeyEnvelopes): GroupKeySource + companion object { @Provides fun provideChatCipher(): ChatCipher = DefaultChatCipher diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt index 454f8b2a56..67475103fb 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -1,7 +1,9 @@ package com.flipcash.services.chat import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.SetKeyEnvelopeError +import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.StoredKeyEnvelope @@ -10,6 +12,7 @@ import com.getcode.ed25519kmp.KeyPair import com.getcode.opencode.model.core.ID import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.test.runTest @@ -87,4 +90,105 @@ class GroupKeyEnvelopesTest { assertContentEquals(creatorKeys.publicKey + memberPk + ByteArray(32) { 5 }, envelope.ciphertext) } + + // Member path: the viewer is memberKeys, admitted by the creator. + + private val creatorId: ID = List(16) { 1 } + private val memberId: ID = List(16) { 2 } + private val memberKeys = KeyPair(publicKey = memberPk, privateKey = ByteArray(64) { 22 }) + private val memberSource = object : ChatKeySource { + override fun ownKeyPair() = memberKeys + override fun ownUserId() = memberId + override suspend fun peerPublicKey(userId: ID): Result = + if (userId == creatorId) Result.success(creatorKeys.publicKey) else Result.failure(IOException("unknown")) + } + private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, memberSource, controller) + private val groupKey = ByteArray(32) { 5 } + + private fun stored(wrapper: KeyPair, recipient: ByteArray, wrappedBy: ID?): StoredKeyEnvelope { + val sealed = FakeChatCipher.wrapGroupKey(wrapper, recipient, chatId.bytes, groupKey) + return StoredKeyEnvelope(KeyEnvelope(1, sealed.nonce, sealed.ciphertext), wrappedBy) + } + + private fun metadata(creator: ID?) = mockk { every { this@mockk.creator } returns creator } + + @Test + fun `a member opens the creator's envelope and stores a self-wrapped copy`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(creatorKeys, memberPk, creatorId)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + val rewrapped = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(rewrapped)) } returns Result.success(Unit) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + + assertContentEquals(memberPk + memberPk + groupKey, rewrapped.captured.ciphertext) + } + + @Test + fun `the key is fetched once`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(memberKeys, memberPk, memberId)) + + memberEnvelopes.key(chatId).getOrThrow() + memberEnvelopes.key(chatId).getOrThrow() + + coVerify(exactly = 1) { controller.getKeyEnvelope(chatId) } + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `an envelope wrapped by someone other than the creator is refused`() = runTest { + val stranger: ID = List(16) { 9 } + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(creatorKeys, memberPk, stranger)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `an envelope that does not open under the creator's key is refused`() = runTest { + val impostor = KeyPair(publicKey = ByteArray(32) { 99.toByte() }, privateKey = ByteArray(64)) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(impostor, memberPk, creatorId)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `ALREADY_SET on the re-wrap reads the envelope another device stored`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.success(stored(creatorKeys, memberPk, creatorId)), + Result.success(stored(memberKeys, memberPk, memberId)), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(SetKeyEnvelopeError.AlreadySet()) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `NO_ENVELOPE regenerates the key only for the creator`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `NO_ENVELOPE for the creator stores a new key and uses it`() = runTest { + val creatorSource = object : ChatKeySource { + override fun ownKeyPair() = creatorKeys + override fun ownUserId() = creatorId + override suspend fun peerPublicKey(userId: ID): Result = error("unused") + } + val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, creatorSource, controller) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + + assertContentEquals(FakeChatCipher.newGroupKey(), creatorEnvelopes.key(chatId).getOrThrow()) + } } From 517afa55d1f6709cf5935b15f2589caac6312ff5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 16:17:08 -0400 Subject: [PATCH 12/25] fix(groups): open the private group encryption sheet from the Encrypted marker The marker in a private group opened the DM copy ("only you and the person you're chatting with"). Pick the sheet from the chat subject. --- .../app/messenger/internal/screens/MessengerScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 7e7d9ef761..b066dc624f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -196,7 +196,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } ChatAction.OpenEncryptionInfo -> keyboard.hideIfVisible { - navigator.push(AppRoute.Messaging.E2eeDmInfo) + // The marker shows in DMs and in private groups; each has its own copy. + val privateGroup = (state.subject as? ChatSubject.Group)?.isPrivate == true + navigator.push( + if (privateGroup) AppRoute.Messaging.E2eePrivateGroupInfo else AppRoute.Messaging.E2eeDmInfo + ) } ChatAction.AddCash -> { From 3f6ca6ffaafa4906e3b0e0e56e99ccb334eb3e4d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 19:41:07 -0400 Subject: [PATCH 13/25] feat(groups): move join requests behind a Requests button in the Chats header The pinned Join Requests row on the Chats list is replaced by a button on the leading side of the Chats header. It shows while a request is pending in either direction and carries a green dot only when someone is waiting on the viewer in a group they host; sent requests alone show the button without a dot. The button opens a Requests screen with two sections. "Waiting on you" lists hosted private groups with people waiting, newest request first, each opening that group's Join Requests. "Sent" lists groups the viewer asked to join, with how long ago, each opening the group's profile where Cancel Request lives. Sent knows only about requests made on this device, since the server can't list them. NotificationDot is the 11dp ring-cut dot from node 11440:189, kept in ui/components so the settings gear can reuse it. RequestAge moves to shared/chat so the tipping module can word the request age. --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 10 +- .../core/src/main/res/values/strings.xml | 9 +- .../screens/components/JoinRequestsAnchor.kt | 2 +- .../screens/profile/GroupProfileScreen.kt | 1 + .../screens/profile/GroupProfileState.kt | 20 -- .../screens/profile/JoinRequestsScreen.kt | 1 + .../screens/profile/PrivateGroupJoinTest.kt | 1 + .../com/flipcash/app/tipping/ChatsScreen.kt | 112 ++------ .../flipcash/app/tipping/RequestsScreen.kt | 259 ++++++++++++++++++ .../app/tipping/internal/ChatsViewModel.kt | 50 ++-- .../app/tipping/internal/RequestsViewModel.kt | 139 ++++++++++ ...uestsTest.kt => RequestsProjectionTest.kt} | 50 ++-- .../com/flipcash/shared/chat/RequestAge.kt | 27 ++ .../getcode/ui/components/NotificationDot.kt | 59 ++++ .../com/getcode/ui/components/TitleBar.kt | 6 + 16 files changed, 593 insertions(+), 155 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt rename apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/{HostedJoinRequestsTest.kt => RequestsProjectionTest.kt} (61%) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RequestAge.kt create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/NotificationDot.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 088878fc7c..83f2a247c5 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -26,6 +26,7 @@ import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.FindByUsernameScreen import com.flipcash.app.tipping.ArchivedChatsScreen +import com.flipcash.app.tipping.RequestsScreen import com.flipcash.app.tipping.NewChatScreen import com.flipcash.app.tipping.NewGroupFlowScreen import com.flipcash.app.tipping.ChatsScreen @@ -138,6 +139,7 @@ fun appEntryProvider( annotatedEntry { key -> ProfileScreen(key.address, key.origin) } annotatedEntry { NewChatScreen() } annotatedEntry { ArchivedChatsScreen() } + annotatedEntry { RequestsScreen() } annotatedEntry { FindByUsernameScreen() } annotatedEntry { key -> NewGroupFlowScreen(route = key, resultStateRegistry = resultStateRegistry) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 3a393dc375..d2269b7698 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -364,7 +364,7 @@ sealed interface AppRoute : NavKey, Parcelable { // Chat pushes the transcript on top of it. val openOnProfile: Boolean = false, // Open on a private group's Join Requests, over its profile so back lands there. Set by - // the chat list's join-requests row. + // the Requests screen's hosted rows. val openOnJoinRequests: Boolean = false, ) : Messaging, FlowRoute { override val initialStack: List @@ -396,6 +396,14 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize data object ArchivedChats : Messaging + /** + * Join requests in both directions, behind the Requests button on the Chats header. Pushed + * for the reason [ArchivedChats] is. + */ + @Serializable + @Parcelize + data object Requests : Messaging + /** * Another person's profile, opened on its own rather than from inside a chat — the * destination of a `flipcash.com/{username}` or `flipcash.com/{uuid}` link. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f9908af1ca..371723ea15 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1134,14 +1134,15 @@ Couldn\'t Deny Something went wrong. Please try again Join Requests + + Requests + Waiting on you + Sent + No pending requests %1$d person is waiting %1$d people are waiting - - %1$d person wants to join %2$s - %1$d people want to join %2$s - requested just now requested %1$dm ago requested %1$dh ago diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt index 8cc6279a47..dfd58a3f8c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt @@ -29,9 +29,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import com.flipcash.app.messenger.internal.screens.profile.RequestAge import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.chat.RequestAge import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.common.ui.profile.chatterName import com.getcode.theme.CodeTheme 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 dc8adfe399..e978d977f1 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 @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable +import com.flipcash.shared.chat.RequestAge import com.getcode.theme.White10 import com.getcode.theme.White05 import androidx.compose.ui.res.painterResource diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt index 07da19da19..86575bee83 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt @@ -258,26 +258,6 @@ internal sealed interface PrivateGroupJoin { } } -/** How long ago a join request went in, as "Requested 2h ago" states it. */ -internal sealed interface RequestAge { - data object JustNow : RequestAge - data class Minutes(val count: Long) : RequestAge - data class Hours(val count: Long) : RequestAge - data class Days(val count: Long) : RequestAge - - companion object { - fun of(at: Instant, now: Instant): RequestAge { - val elapsed = (now - at).coerceAtLeast(Duration.ZERO) - return when { - elapsed < 1.minutes -> JustNow - elapsed < 1.hours -> Minutes(elapsed.inWholeMinutes) - elapsed < 1.days -> Hours(elapsed.inWholeHours) - else -> Days(elapsed.inWholeDays) - } - } - } -} - /** How long ago a member joined, as a private group's Members list words it (node 11210:70). */ internal sealed interface JoinAge { data object JustNow : JoinAge 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 e18d9b7548..7f4b9c771d 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 @@ -37,6 +37,7 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.openGroupInvite import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.chat.RequestAge import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.common.ui.profile.chatterName import com.getcode.navigation.core.LocalCodeNavigator diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt index 3dd42308fd..19dddb7654 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt @@ -8,6 +8,7 @@ import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +import com.flipcash.shared.chat.RequestAge class PrivateGroupJoinTest { diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 12acc37c83..6f4a2eb961 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -49,12 +49,6 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import com.flipcash.app.tipping.internal.HostedJoinRequests -import com.getcode.theme.White10 -import androidx.compose.runtime.setValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.draw.clip import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.outlined.PersonAdd @@ -93,6 +87,7 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.toast.LocalFloatingToastHost import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.WithNotificationDot import com.getcode.ui.components.TopScrollEdge import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow @@ -136,7 +131,7 @@ fun ChatsScreen() { val visible = state.visibleChats // Everything between the chips and the filler, in list order. val parkKeys: List = remember(state.chats, state.hasNoChatsAtAll, visible) { - listOf("join_requests", "archived") + when { + listOf("archived") + when { !chats.isLoaded() -> listOf("loading") state.hasNoChatsAtAll -> listOf("empty") visible.isEmpty() -> listOf("empty_filter") @@ -177,6 +172,14 @@ fun ChatsScreen() { // Node 9442:5779 — the only way to start a chat with someone who has never paid // you. A pushed route rather than an in-place screen: this list is a tab home, and // anything drawn inside it would leave the tab bar over the entry screen. + // Join requests in either direction; the dot only while someone waits on the viewer. + leadingContent = { + if (state.showsRequestsButton) { + RequestsButton(needsDecision = state.needsDecision) { + navigator.push(AppRoute.Messaging.Requests) + } + } + }, endContent = { AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) } }, @@ -238,32 +241,6 @@ fun ChatsScreen() { // loading) and the parked position has a stable anchor; empty when there is no row. // Its key never leaves the list, so animateItem only moves it; the row itself fades in // and out inside the slot, while the rows below slide to make room or close the gap. - // Same shape as the Archived slot below: always emitted, empty while nobody waits. - item(key = "join_requests", contentType = "join_requests") { - Box(Modifier.animateItem().then(parkFiller.tracked("join_requests"))) { - var shown by remember { mutableStateOf(state.joinRequests) } - if (state.joinRequests != null) shown = state.joinRequests - AnimatedVisibility( - visible = state.showsJoinRequestsRow, - enter = fadeIn(), - exit = fadeOut(), - ) { - val requests = shown ?: return@AnimatedVisibility - JoinRequestsRow( - requests = requests, - onClick = { - navigator.push( - AppRoute.Messaging.Chat( - ChatIdentifier.ByChatId(requests.chatId), - openOnJoinRequests = true, - ) - ) - }, - ) - } - } - } - item(key = "archived", contentType = "archived") { Box(Modifier.animateItem().then(parkFiller.tracked("archived"))) { AnimatedVisibility( @@ -533,64 +510,27 @@ private fun ChatFilterRow( } /** - * Node 11214:67 — the host's way into Join Requests, pinned above Archived: "3 people want to join - * Moony" with a plain count, like Archived's, so it does not read as unread messages. + * The way into Requests, on the leading side of the header. The dot marks someone waiting on the + * viewer; sent requests alone show the button bare. */ @Composable -private fun JoinRequestsRow(requests: HostedJoinRequests, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(CodeTheme.colors.background) - .clickable(onClick = onClick) - .testTag("join_requests_row") - .padding(vertical = CodeTheme.dimens.grid.x3) - .padding(start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - Box( +private fun RequestsButton(needsDecision: Boolean, onClick: () -> Unit) { + val description = stringResource(R.string.title_requests) + WithNotificationDot(isShown = needsDecision) { + Icon( modifier = Modifier - .size(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape) - .background(White10), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.PersonAdd, - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.title_joinRequests), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - Text( - text = pluralStringResource( - R.plurals.subtitle_joinRequestsForGroup, - requests.count, - requests.count, - requests.groupName, - ), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Text( - text = requests.count.toString(), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + .clickable(onClick = onClick) + .clearAndSetSemantics { + contentDescription = description + role = Role.Button + } + .testTag("requests_button") + .padding(CodeTheme.dimens.grid.x1) + .size(CodeTheme.dimens.staticGrid.x5), + imageVector = Icons.Outlined.PersonAdd, contentDescription = null, - tint = CodeTheme.colors.textSecondary, + tint = CodeTheme.colors.textMain, ) } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt new file mode 100644 index 0000000000..be99afae22 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt @@ -0,0 +1,259 @@ +package com.flipcash.app.tipping + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.tipping.internal.HostedRequest +import com.flipcash.app.tipping.internal.RequestsViewModel +import com.flipcash.app.tipping.internal.SentRequest +import com.flipcash.features.tipping.R +import com.flipcash.shared.chat.RequestAge +import com.flipcash.shared.chat.ui.ChatListRow +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.SubtitleText +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.text.SectionHeader +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * Join requests in both directions, behind the Requests button on the Chats header: private groups + * the viewer hosts with people waiting ("Waiting on you"), and groups the viewer asked to join + * ("Sent"). Sent knows only about requests made on this device; the server can't list them. + */ +@Composable +fun RequestsScreen() { + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_requests), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("requests_list"), + contentPadding = PaddingValues(top = padding.calculateTopPadding()), + ) { + if (state.isLoaded && state.isEmpty) { + item(key = "empty") { + Box( + modifier = Modifier + .animateItem() + .fillParentMaxSize() + .padding(bottom = padding.calculateBottomPadding()), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.title_noPendingRequests), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + + if (state.hosted.isNotEmpty()) { + item(key = "hosted_header") { + SectionHeader( + modifier = Modifier.animateItem(), + title = stringResource(R.string.header_waitingOnYou), + ) + } + itemsIndexed(state.hosted, key = { _, it -> "hosted_${it.chat.chatId}" }) { index, request -> + HostedRequestRow( + modifier = Modifier.animateItem(), + request = request, + showDivider = index < state.hosted.lastIndex, + ) { + navigator.push( + AppRoute.Messaging.Chat( + ChatIdentifier.ByChatId(request.chat.chatId), + openOnJoinRequests = true, + ) + ) + } + } + } + + if (state.sent.isNotEmpty()) { + item(key = "sent_header") { + SectionHeader( + modifier = Modifier.animateItem(), + title = stringResource(R.string.header_sentRequests), + ) + } + itemsIndexed(state.sent, key = { _, it -> "sent_${it.chat.chatId}" }) { index, request -> + SentRequestRow( + modifier = Modifier.animateItem(), + request = request, + showDivider = index < state.sent.lastIndex, + ) { + // The group's profile, where Cancel Request lives: the viewer isn't a member. + navigator.push( + AppRoute.Messaging.Chat( + ChatIdentifier.ByChatId(request.chat.chatId), + openOnProfile = true, + ) + ) + } + } + } + } + } +} + +/** A hosted group with people waiting: the count is secondary, like Archived's, not an unread badge. */ +@Composable +private fun HostedRequestRow( + request: HostedRequest, + showDivider: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + RequestRow( + modifier = modifier.testTag("requests_hosted_row"), + chat = request.chat, + subtitle = pluralStringResource(R.plurals.subtitle_joinRequestsWaiting, request.count, request.count), + count = request.count, + showDivider = showDivider, + onClick = onClick, + ) +} + +@Composable +private fun SentRequestRow( + request: SentRequest, + showDivider: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + RequestRow( + modifier = modifier.testTag("requests_sent_row"), + chat = request.chat, + subtitle = requestedAgo(request.requestedAt), + count = null, + showDivider = showDivider, + onClick = onClick, + ) +} + +@Composable +private fun RequestRow( + chat: ConversationReference, + subtitle: String, + count: Int?, + showDivider: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + ChatListRow( + modifier = modifier, + avatar = { + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape), + ) + }, + title = { + Text( + modifier = Modifier.weight(1f), + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + subtitle = { SubtitleText(subtitle) }, + endAction = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + if (count != null) { + Text( + text = count.toString(), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + }, + showDivider = showDivider, + onClick = onClick, + ) +} + +/** "Requested 2h ago", redrawn each minute, the only granularity it shows. */ +@Composable +private fun requestedAgo(at: Instant): String { + var now by remember { mutableStateOf(Clock.System.now()) } + LaunchedEffect(Unit) { + while (true) { + delay(1.minutes) + now = Clock.System.now() + } + } + return when (val age = RequestAge.of(at, now)) { + RequestAge.JustNow -> stringResource(R.string.title_requestedJustNow) + is RequestAge.Minutes -> stringResource(R.string.action_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.action_requestedHoursAgo, age.count) + is RequestAge.Days -> if (age.count == 1L) { + stringResource(R.string.title_requestedYesterday) + } else { + stringResource(R.string.action_requestedDaysAgo, age.count) + } + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index a4e89b41a2..a2b8dc8a8e 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -63,9 +63,14 @@ internal class ChatsViewModel @Inject constructor( // Whether a pull from the top has brought the chips on. Held here rather than in the // screen so it survives opening a chat and coming back. val chipsRevealed: Boolean = false, - // The pinned Join Requests row: the viewer's hosted private group with the newest request. - val joinRequests: HostedJoinRequests? = null, + // Someone waits in a private group the viewer hosts: the Requests button's dot. + val needsDecision: Boolean = false, + // The viewer has asked to join a private group and not heard back. + val hasSentRequests: Boolean = false, ) { + /** The Requests button in the header: while a request is pending in either direction. */ + val showsRequestsButton: Boolean get() = needsDecision || hasSentRequests + /** The chips stay once revealed, and always show under a filter other than All. */ val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All @@ -73,10 +78,6 @@ internal class ChatsViewModel @Inject constructor( val showsArchivedRow: Boolean get() = filter == ChatListFilter.All && projection.archivedRowVisible - /** The Join Requests row: only under All, like Archived, and only while someone waits. */ - val showsJoinRequestsRow: Boolean - get() = filter == ChatListFilter.All && joinRequests != null - /** Nothing in the main list and nothing archived: the full-screen prompt. */ val hasNoChatsAtAll: Boolean get() = chats.dataOrNull.orEmpty().isEmpty() && !projection.archivedRowVisible @@ -101,7 +102,7 @@ internal class ChatsViewModel @Inject constructor( data object ChipsRevealed : Event - data class JoinRequestsUpdated(val joinRequests: HostedJoinRequests?) : Event + data class RequestsUpdated(val needsDecision: Boolean, val hasSentRequests: Boolean) : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -185,9 +186,15 @@ internal class ChatsViewModel @Inject constructor( hostedGroups .onEach { groups -> groups.keys.forEach { chatId -> viewModelScope.launch { hostLobbies.load(chatId) } } } .launchIn(viewModelScope) - combine(hostedGroups, hostLobbies.state, ::hostedJoinRequests) + combine( + combine(hostedGroups, hostLobbies.state) { hosted, lobbies -> + hostedRequests(hosted.keys, lobbies).isNotEmpty() + }, + lobbyRequests.observe().map { it.isNotEmpty() }, + Event::RequestsUpdated, + ) .distinctUntilChanged() - .onEach { dispatchEvent(Event.JoinRequestsUpdated(it)) } + .onEach { dispatchEvent(it) } .launchIn(viewModelScope) } @@ -200,15 +207,14 @@ internal class ChatsViewModel @Inject constructor( // A chip can only be tapped once the row is on screen, so choosing one reveals it. is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } - is Event.JoinRequestsUpdated -> { state -> state.copy(joinRequests = event.joinRequests) } + is Event.RequestsUpdated -> { state -> + state.copy(needsDecision = event.needsDecision, hasSentRequests = event.hasSentRequests) + } } } } } -/** The chat list's Join Requests row: which group it opens, by name, and how many wait there. */ -internal data class HostedJoinRequests(val chatId: ChatId, val groupName: String, val count: Int) - /** Private groups [selfId] created, by id, with their titles. */ internal fun hostedPrivateGroups(summaries: List, selfId: ID?): Map { selfId ?: return emptyMap() @@ -219,16 +225,12 @@ internal fun hostedPrivateGroups(summaries: List, selfId: ID?): Map } /** - * The hosted group whose newest request is the most recent, or null when nobody waits anywhere. - * There is no frame for a switcher between hosted groups, so the row names one group and opens it. + * The hosted groups with someone waiting, each with its waiting list, the group whose newest + * request is the most recent first. */ -internal fun hostedJoinRequests( - hosted: Map, +internal fun hostedRequests( + hosted: Collection, lobbies: Map>, -): HostedJoinRequests? = hosted - .mapNotNull { (chatId, name) -> - val waiting = lobbies[chatId].orEmpty() - if (waiting.isEmpty()) null else Triple(chatId, name, waiting) - } - .maxByOrNull { (_, _, waiting) -> waiting.maxOf { it.enteredAt } } - ?.let { (chatId, name, waiting) -> HostedJoinRequests(chatId, name, waiting.size) } +): List>> = hosted + .mapNotNull { chatId -> lobbies[chatId]?.takeIf { it.isNotEmpty() }?.let { chatId to it } } + .sortedByDescending { (_, waiting) -> waiting.maxOf { it.enteredAt } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt new file mode 100644 index 0000000000..537d6441cf --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt @@ -0,0 +1,139 @@ +package com.flipcash.app.tipping.internal + +import androidx.lifecycle.viewModelScope +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.LobbyRequests +import com.flipcash.shared.chat.chatListFeeds +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.toConversationReference +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Instant + +/** + * Backs the Requests screen: join requests in both directions. Private groups the viewer hosts with + * people waiting, and groups the viewer has asked to join. + */ +@HiltViewModel +internal class RequestsViewModel @Inject constructor( + chatCoordinator: ChatCoordinator, + private val chatController: ChatController, + userManager: UserManager, + resources: ResourceHelper, + private val lobbyRequests: LobbyRequests, + private val hostLobbies: HostLobbies, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State( + val hosted: List = emptyList(), + val sent: List = emptyList(), + // False until every sent request's group has been asked about, so the empty message does + // not flash over a Sent row that is about to arrive. + val isLoaded: Boolean = false, + ) { + val isEmpty: Boolean get() = hosted.isEmpty() && sent.isEmpty() + } + + sealed interface Event { + data class Updated(val hosted: List, val sent: List, val isLoaded: Boolean) : Event + } + + // Groups the viewer asked to join are not in the feed, so each one's metadata is fetched here. + // A failed fetch is remembered so the screen still settles, with that group left out. + private val fetched = MutableStateFlow>(emptyMap()) + + init { + // Drops requests that were answered while the app was away, as the Chats list does. + viewModelScope.launch { lobbyRequests.refresh() } + + val selfId = { userManager.accountId } + fun reference(metadata: ChatMetadata) = + ChatSummary(metadata, unreadCount = 0).toConversationReference(selfId(), emptyMap(), resources) + + val hostedGroups = chatCoordinator.chatListFeeds() + .map { feeds -> + val all = feeds.main + feeds.archived + val ids = hostedPrivateGroups(all, selfId()).keys + all.map { it.metadata }.filter { it.chatId in ids }.associateBy { it.chatId } + } + .distinctUntilChanged() + + // Fresh counts on open; LobbyUpdate only reaches a running stream. + viewModelScope.launch { + hostedGroups.first().keys.forEach { chatId -> launch { hostLobbies.load(chatId) } } + } + + lobbyRequests.observe() + .onEach { requests -> + requests.keys.filter { it !in fetched.value }.forEach { chatId -> + viewModelScope.launch { + val metadata = chatController.getChat(chatId).getOrNull() + fetched.update { it + (chatId to metadata) } + } + } + } + .launchIn(viewModelScope) + + combine(hostedGroups, hostLobbies.state, lobbyRequests.observe(), fetched) { hosted, lobbies, requests, fetched -> + val hostedRows = hostedRequests(hosted.keys, lobbies).mapNotNull { (chatId, waiting) -> + hosted[chatId]?.let { HostedRequest(reference(it), waiting.size) } + } + val sentRows = sentRequests(requests, fetched.filterValues { it != null }.mapValues { it.value!! }) + .map { (metadata, at) -> SentRequest(reference(metadata), at) } + Event.Updated(hostedRows, sentRows, isLoaded = requests.keys.all { it in fetched }) + } + .flowOn(dispatchers.Default) + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + } + + internal companion object { + internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Updated -> { state -> + state.copy(hosted = event.hosted, sent = event.sent, isLoaded = event.isLoaded) + } + } + } + } +} + +/** A private group the viewer hosts, with how many people wait to join it. */ +internal data class HostedRequest(val chat: ConversationReference, val count: Int) + +/** A private group the viewer asked to join, with when the request went in. */ +internal data class SentRequest(val chat: ConversationReference, val requestedAt: Instant) + +/** + * The groups the viewer asked to join, newest request first. A group whose metadata isn't held is + * left out until it is fetched. + */ +internal fun sentRequests( + requests: Map, + metadata: Map, +): List> = requests + .mapNotNull { (chatId, at) -> metadata[chatId]?.let { it to at } } + .sortedByDescending { (_, at) -> at } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/HostedJoinRequestsTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt similarity index 61% rename from apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/HostedJoinRequestsTest.kt rename to apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt index 736b849fb1..ef5c32dc1e 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/HostedJoinRequestsTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt @@ -5,18 +5,15 @@ 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.LobbyMember -import com.flipcash.shared.chat.ChatFeeds -import com.flipcash.shared.chat.ChatListFilter import com.flipcash.shared.chat.ChatSummary import com.getcode.solana.keys.PublicKey import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant -class HostedJoinRequestsTest { +class RequestsProjectionTest { private val self = listOf(9) private val other = listOf(8) @@ -65,37 +62,52 @@ class HostedJoinRequestsTest { } @Test - fun `no row while nobody waits`() { - val hosted = hostedPrivateGroups(listOf(moony, yotes), self) - assertNull(hostedJoinRequests(hosted, emptyMap())) - assertNull(hostedJoinRequests(hosted, mapOf(moony.metadata.chatId to emptyList()))) + fun `nobody waiting, no hosted requests`() { + val hosted = hostedPrivateGroups(listOf(moony, yotes), self).keys + assertTrue(hostedRequests(hosted, emptyMap()).isEmpty()) + assertTrue(hostedRequests(hosted, mapOf(moony.metadata.chatId to emptyList())).isEmpty()) } @Test - fun `the row names the group with the newest request and counts its lobby`() { - val hosted = hostedPrivateGroups(listOf(moony, yotes), self) - val row = hostedJoinRequests( + fun `hosted groups with someone waiting, newest request first`() { + val hosted = hostedPrivateGroups(listOf(moony, yotes), self).keys + val requests = hostedRequests( hosted, mapOf( moony.metadata.chatId to listOf(member(1, 100), member(2, 150), member(3, 120)), yotes.metadata.chatId to listOf(member(4, 300)), ), ) - assertEquals(HostedJoinRequests(yotes.metadata.chatId, "Yotes", 1), row) + assertEquals(listOf(yotes.metadata.chatId, moony.metadata.chatId), requests.map { it.first }) + assertEquals(listOf(1, 3), requests.map { it.second.size }) } @Test fun `a lobby for a group the viewer does not host is ignored`() { val theirs = group(3, "Theirs", other) - val hosted = hostedPrivateGroups(listOf(moony, theirs), self) - assertNull(hostedJoinRequests(hosted, mapOf(theirs.metadata.chatId to listOf(member(1, 100))))) + val hosted = hostedPrivateGroups(listOf(moony, theirs), self).keys + assertTrue(hostedRequests(hosted, mapOf(theirs.metadata.chatId to listOf(member(1, 100)))).isEmpty()) } @Test - fun `the row shows only under All`() { - val row = HostedJoinRequests(moony.metadata.chatId, "Moony", 2) - assertTrue(ChatsViewModel.State(joinRequests = row).showsJoinRequestsRow) - assertFalse(ChatsViewModel.State(joinRequests = row, filter = ChatListFilter.Unread).showsJoinRequestsRow) - assertFalse(ChatsViewModel.State().showsJoinRequestsRow) + fun `the Requests button shows for a request either way, the dot only for one to decide`() { + assertFalse(ChatsViewModel.State().showsRequestsButton) + assertTrue(ChatsViewModel.State(needsDecision = true).showsRequestsButton) + assertTrue(ChatsViewModel.State(hasSentRequests = true).showsRequestsButton) + } + + @Test + fun `sent requests are newest first and skip a group not fetched yet`() { + val requested = mapOf( + moony.metadata.chatId to Instant.fromEpochSeconds(100), + yotes.metadata.chatId to Instant.fromEpochSeconds(300), + ChatId(ByteArray(16) { 7 }.toList()) to Instant.fromEpochSeconds(200), + ) + val sent = sentRequests( + requested, + mapOf(moony.metadata.chatId to moony.metadata, yotes.metadata.chatId to yotes.metadata), + ) + assertEquals(listOf(yotes.metadata, moony.metadata), sent.map { it.first }) + assertEquals(Instant.fromEpochSeconds(300), sent.first().second) } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RequestAge.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RequestAge.kt new file mode 100644 index 0000000000..6881898ef6 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RequestAge.kt @@ -0,0 +1,27 @@ +package com.flipcash.shared.chat + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** How long ago a join request went in, as "Requested 2h ago" states it. */ +sealed interface RequestAge { + data object JustNow : RequestAge + data class Minutes(val count: Long) : RequestAge + data class Hours(val count: Long) : RequestAge + data class Days(val count: Long) : RequestAge + + companion object { + fun of(at: Instant, now: Instant): RequestAge { + val elapsed = (now - at).coerceAtLeast(Duration.ZERO) + return when { + elapsed < 1.minutes -> JustNow + elapsed < 1.hours -> Minutes(elapsed.inWholeMinutes) + elapsed < 1.days -> Hours(elapsed.inWholeHours) + else -> Days(elapsed.inWholeDays) + } + } + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/NotificationDot.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/NotificationDot.kt new file mode 100644 index 0000000000..795a0a9332 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/NotificationDot.kt @@ -0,0 +1,59 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme + +/** + * Node 11440:189 — a small filled dot that marks an icon as needing attention, ringed in the + * background colour so it reads as cut out of the icon. No count. + */ +@Composable +fun NotificationDot( + modifier: Modifier = Modifier, + color: Color = CodeTheme.colors.success, + ring: Color = CodeTheme.colors.background, +) { + // The ring sits outside the fill: the design's 11dp dot plus a 2dp stroke. + Box( + modifier = modifier + .background(ring, CircleShape) + .padding(2.dp) + .size(11.dp) + .background(color, CircleShape), + ) +} + +/** + * Draws [content] with a [NotificationDot] on its top-end corner while [isShown]. The default + * [offset] hangs the dot off a bare glyph's corner, as iOS does; a circular button, whose corner is + * already empty, wants [DpOffset.Zero]. + */ +@Composable +fun WithNotificationDot( + isShown: Boolean, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(5.dp, (-5).dp), + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + content() + if (isShown) { + NotificationDot( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = offset.x, y = offset.y), + ) + } + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt index 1d123d761f..ba5feae264 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt @@ -253,6 +253,10 @@ fun AppBarWithTitle( // the sheet-root Close, which sits on the trailing edge. leadingDismiss: Boolean = false, hazeState: HazeState? = null, + // A screen's own leading control, for a root screen with no back or close to put there. Ignored + // when [onBackIconClicked] asks for the nav control. A centred title keeps its place whether + // this draws anything or not: the base reserves a back button's width either way. + leadingContent: @Composable () -> Unit = { }, endContent: @Composable RowScope.() -> Unit = { }, ) { val navigator = LocalCodeNavigator.current @@ -286,6 +290,8 @@ fun AppBarWithTitle( } else if (!showClose) { AppBarDefaults.UpNavigation(hazeState = hazeState) { onBackIconClicked() } } + } else { + leadingContent() } }, titleRegion = { From 163f1af6c881cf2e87a3d7a9854b570408e2e7fc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 21:04:37 -0400 Subject: [PATCH 14/25] feat(groups): put Requests behind an overflow menu in the Chats header The leading header item is now a three-dot menu with one item, Requests, as on iOS. It shows while a request is pending either way; the dot sits on the three-dot icon and marks only requests the viewer has to decide. The item's second line counts the people waiting. --- .../core/src/main/res/values/strings.xml | 5 + .../com/flipcash/app/tipping/ChatsScreen.kt | 99 ++++++++++++++----- .../app/tipping/internal/ChatsViewModel.kt | 18 ++-- .../internal/RequestsProjectionTest.kt | 10 +- 4 files changed, 99 insertions(+), 33 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 371723ea15..972fe92ddf 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1143,6 +1143,11 @@ %1$d person is waiting %1$d people are waiting + + %1$d person waiting + %1$d people waiting + + More, people waiting to join requested just now requested %1$dm ago requested %1$dh ago diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 6f4a2eb961..ff630f877e 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -54,7 +54,14 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.pluralStringResource +import com.getcode.theme.extraLarge import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -174,8 +181,8 @@ fun ChatsScreen() { // anything drawn inside it would leave the tab bar over the entry screen. // Join requests in either direction; the dot only while someone waits on the viewer. leadingContent = { - if (state.showsRequestsButton) { - RequestsButton(needsDecision = state.needsDecision) { + if (state.showsRequestsMenu) { + RequestsMenu(waitingCount = state.waitingCount) { navigator.push(AppRoute.Messaging.Requests) } } @@ -510,28 +517,76 @@ private fun ChatFilterRow( } /** - * The way into Requests, on the leading side of the header. The dot marks someone waiting on the - * viewer; sent requests alone show the button bare. + * The header's leading overflow menu, whose one item is the way into Requests. The dot marks + * someone waiting on the viewer; sent requests alone show the button bare. */ @Composable -private fun RequestsButton(needsDecision: Boolean, onClick: () -> Unit) { - val description = stringResource(R.string.title_requests) - WithNotificationDot(isShown = needsDecision) { - Icon( - modifier = Modifier - .clip(CircleShape) - .clickable(onClick = onClick) - .clearAndSetSemantics { - contentDescription = description - role = Role.Button - } - .testTag("requests_button") - .padding(CodeTheme.dimens.grid.x1) - .size(CodeTheme.dimens.staticGrid.x5), - imageVector = Icons.Outlined.PersonAdd, - contentDescription = null, - tint = CodeTheme.colors.textMain, - ) +private fun RequestsMenu(waitingCount: Int, onRequests: () -> Unit) { + var expanded by remember { mutableStateOf(false) } + val description = if (waitingCount > 0) { + stringResource(R.string.content_description_moreWithRequests) + } else { + stringResource(R.string.action_moreProfileActions) + } + Box { + WithNotificationDot(isShown = waitingCount > 0) { + Icon( + modifier = Modifier + .clip(CircleShape) + .clickable { expanded = true } + .clearAndSetSemantics { + contentDescription = description + role = Role.Button + } + .testTag("chats_overflow_menu") + .padding(CodeTheme.dimens.grid.x1) + .size(CodeTheme.dimens.staticGrid.x5), + imageVector = Icons.Rounded.MoreVert, + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + } + // Same surface as the profile's overflow menu. + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + ) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Outlined.PersonAdd, + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + }, + text = { + Column { + Text( + text = stringResource(R.string.title_requests), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + if (waitingCount > 0) { + Text( + text = pluralStringResource( + R.plurals.subtitle_peopleWaiting, + waitingCount, + waitingCount, + ), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + } + }, + onClick = { + expanded = false + onRequests() + }, + ) + } } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index a2b8dc8a8e..7055784ee4 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -63,13 +63,17 @@ internal class ChatsViewModel @Inject constructor( // Whether a pull from the top has brought the chips on. Held here rather than in the // screen so it survives opening a chat and coming back. val chipsRevealed: Boolean = false, - // Someone waits in a private group the viewer hosts: the Requests button's dot. - val needsDecision: Boolean = false, + // How many people wait across the private groups the viewer hosts: the overflow menu's + // dot, and the Requests item's secondary line. + val waitingCount: Int = 0, // The viewer has asked to join a private group and not heard back. val hasSentRequests: Boolean = false, ) { - /** The Requests button in the header: while a request is pending in either direction. */ - val showsRequestsButton: Boolean get() = needsDecision || hasSentRequests + /** Someone waits on the viewer to let them in. */ + val needsDecision: Boolean get() = waitingCount > 0 + + /** The overflow menu in the header: while a request is pending in either direction. */ + val showsRequestsMenu: Boolean get() = needsDecision || hasSentRequests /** The chips stay once revealed, and always show under a filter other than All. */ val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All @@ -102,7 +106,7 @@ internal class ChatsViewModel @Inject constructor( data object ChipsRevealed : Event - data class RequestsUpdated(val needsDecision: Boolean, val hasSentRequests: Boolean) : Event + data class RequestsUpdated(val waitingCount: Int, val hasSentRequests: Boolean) : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -188,7 +192,7 @@ internal class ChatsViewModel @Inject constructor( .launchIn(viewModelScope) combine( combine(hostedGroups, hostLobbies.state) { hosted, lobbies -> - hostedRequests(hosted.keys, lobbies).isNotEmpty() + hostedRequests(hosted.keys, lobbies).sumOf { (_, waiting) -> waiting.size } }, lobbyRequests.observe().map { it.isNotEmpty() }, Event::RequestsUpdated, @@ -208,7 +212,7 @@ internal class ChatsViewModel @Inject constructor( is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } is Event.RequestsUpdated -> { state -> - state.copy(needsDecision = event.needsDecision, hasSentRequests = event.hasSentRequests) + state.copy(waitingCount = event.waitingCount, hasSentRequests = event.hasSentRequests) } } } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt index ef5c32dc1e..1feb9132d4 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt @@ -90,10 +90,12 @@ class RequestsProjectionTest { } @Test - fun `the Requests button shows for a request either way, the dot only for one to decide`() { - assertFalse(ChatsViewModel.State().showsRequestsButton) - assertTrue(ChatsViewModel.State(needsDecision = true).showsRequestsButton) - assertTrue(ChatsViewModel.State(hasSentRequests = true).showsRequestsButton) + fun `the overflow menu shows for a request either way, the dot only for one to decide`() { + assertFalse(ChatsViewModel.State().showsRequestsMenu) + assertTrue(ChatsViewModel.State(waitingCount = 2).showsRequestsMenu) + assertTrue(ChatsViewModel.State(waitingCount = 2).needsDecision) + assertTrue(ChatsViewModel.State(hasSentRequests = true).showsRequestsMenu) + assertFalse(ChatsViewModel.State(hasSentRequests = true).needsDecision) } @Test From e512d98fb067a9ef3b7c709c9f462fa7cba7ae37 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 21:20:06 -0400 Subject: [PATCH 15/25] feat(groups): always show the Chats overflow menu and dot its Requests item As on iOS, the menu shows with no pending requests, since Requests has an empty state. The Requests item drops its people-waiting line and carries the green dot instead, and the header dot moves in to sit just above and right of the top dot. --- .../core/src/main/res/values/strings.xml | 5 +- .../com/flipcash/app/tipping/ChatsScreen.kt | 75 +++++++++++-------- .../app/tipping/internal/ChatsViewModel.kt | 27 ++----- .../internal/RequestsProjectionTest.kt | 9 --- 4 files changed, 52 insertions(+), 64 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 972fe92ddf..4cac077803 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1143,11 +1143,8 @@ %1$d person is waiting %1$d people are waiting - - %1$d person waiting - %1$d people waiting - More, people waiting to join + Requests, people waiting to join requested just now requested %1$dm ago requested %1$dh ago diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index ff630f877e..aa56d6c0d1 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -60,7 +60,10 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.offset +import com.getcode.ui.components.NotificationDot import com.getcode.theme.extraLarge import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -179,12 +182,11 @@ fun ChatsScreen() { // Node 9442:5779 — the only way to start a chat with someone who has never paid // you. A pushed route rather than an in-place screen: this list is a tab home, and // anything drawn inside it would leave the tab bar over the entry screen. - // Join requests in either direction; the dot only while someone waits on the viewer. + // Always shown: Requests has its own empty state. The dot only while someone waits + // on the viewer. leadingContent = { - if (state.showsRequestsMenu) { - RequestsMenu(waitingCount = state.waitingCount) { - navigator.push(AppRoute.Messaging.Requests) - } + RequestsMenu(needsDecision = state.needsDecision) { + navigator.push(AppRoute.Messaging.Requests) } }, endContent = { @@ -517,19 +519,21 @@ private fun ChatFilterRow( } /** - * The header's leading overflow menu, whose one item is the way into Requests. The dot marks - * someone waiting on the viewer; sent requests alone show the button bare. + * The header's leading overflow menu, whose one item is the way into Requests. The dot, on the + * menu and on its Requests item, marks someone waiting on the viewer. */ @Composable -private fun RequestsMenu(waitingCount: Int, onRequests: () -> Unit) { +private fun RequestsMenu(needsDecision: Boolean, onRequests: () -> Unit) { var expanded by remember { mutableStateOf(false) } - val description = if (waitingCount > 0) { + val description = if (needsDecision) { stringResource(R.string.content_description_moreWithRequests) } else { stringResource(R.string.action_moreProfileActions) } Box { - WithNotificationDot(isShown = waitingCount > 0) { + // Pulled in from the corner so the dot sits just above and right of the top dot, as a + // badge on the glyph rather than off the button's padding. + WithNotificationDot(isShown = needsDecision, offset = DpOffset((-3).dp, (-1).dp)) { Icon( modifier = Modifier .clip(CircleShape) @@ -553,34 +557,41 @@ private fun RequestsMenu(waitingCount: Int, onRequests: () -> Unit) { containerColor = CodeTheme.colors.brandLight, shape = CodeTheme.shapes.extraLarge, ) { + val requestsDescription = if (needsDecision) { + stringResource(R.string.content_description_requestsWaiting) + } else { + stringResource(R.string.title_requests) + } DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = Icons.Outlined.PersonAdd, - contentDescription = null, - tint = CodeTheme.colors.textMain, - ) + modifier = Modifier.clearAndSetSemantics { + contentDescription = requestsDescription + role = Role.Button }, - text = { - Column { - Text( - text = stringResource(R.string.title_requests), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textMain, + leadingIcon = { + Box { + Icon( + imageVector = Icons.Outlined.PersonAdd, + contentDescription = null, + tint = CodeTheme.colors.textMain, ) - if (waitingCount > 0) { - Text( - text = pluralStringResource( - R.plurals.subtitle_peopleWaiting, - waitingCount, - waitingCount, - ), - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, + if (needsDecision) { + // Ringed in the menu's surface, so it reads as cut out of the glyph. + NotificationDot( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-4).dp), + ring = CodeTheme.colors.brandLight, ) } } }, + text = { + Text( + text = stringResource(R.string.title_requests), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + }, onClick = { expanded = false onRequests() diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 7055784ee4..5767c4af18 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -63,17 +63,10 @@ internal class ChatsViewModel @Inject constructor( // Whether a pull from the top has brought the chips on. Held here rather than in the // screen so it survives opening a chat and coming back. val chipsRevealed: Boolean = false, - // How many people wait across the private groups the viewer hosts: the overflow menu's - // dot, and the Requests item's secondary line. - val waitingCount: Int = 0, - // The viewer has asked to join a private group and not heard back. - val hasSentRequests: Boolean = false, + // Someone waits in a private group the viewer hosts: the dot on the overflow menu and on + // its Requests item. + val needsDecision: Boolean = false, ) { - /** Someone waits on the viewer to let them in. */ - val needsDecision: Boolean get() = waitingCount > 0 - - /** The overflow menu in the header: while a request is pending in either direction. */ - val showsRequestsMenu: Boolean get() = needsDecision || hasSentRequests /** The chips stay once revealed, and always show under a filter other than All. */ val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All @@ -106,7 +99,7 @@ internal class ChatsViewModel @Inject constructor( data object ChipsRevealed : Event - data class RequestsUpdated(val waitingCount: Int, val hasSentRequests: Boolean) : Event + data class RequestsUpdated(val needsDecision: Boolean) : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -190,13 +183,9 @@ internal class ChatsViewModel @Inject constructor( hostedGroups .onEach { groups -> groups.keys.forEach { chatId -> viewModelScope.launch { hostLobbies.load(chatId) } } } .launchIn(viewModelScope) - combine( - combine(hostedGroups, hostLobbies.state) { hosted, lobbies -> - hostedRequests(hosted.keys, lobbies).sumOf { (_, waiting) -> waiting.size } - }, - lobbyRequests.observe().map { it.isNotEmpty() }, - Event::RequestsUpdated, - ) + combine(hostedGroups, hostLobbies.state) { hosted, lobbies -> + Event.RequestsUpdated(needsDecision = hostedRequests(hosted.keys, lobbies).isNotEmpty()) + } .distinctUntilChanged() .onEach { dispatchEvent(it) } .launchIn(viewModelScope) @@ -212,7 +201,7 @@ internal class ChatsViewModel @Inject constructor( is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } is Event.RequestsUpdated -> { state -> - state.copy(waitingCount = event.waitingCount, hasSentRequests = event.hasSentRequests) + state.copy(needsDecision = event.needsDecision) } } } diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt index 1feb9132d4..e03414b1a8 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt @@ -89,15 +89,6 @@ class RequestsProjectionTest { assertTrue(hostedRequests(hosted, mapOf(theirs.metadata.chatId to listOf(member(1, 100)))).isEmpty()) } - @Test - fun `the overflow menu shows for a request either way, the dot only for one to decide`() { - assertFalse(ChatsViewModel.State().showsRequestsMenu) - assertTrue(ChatsViewModel.State(waitingCount = 2).showsRequestsMenu) - assertTrue(ChatsViewModel.State(waitingCount = 2).needsDecision) - assertTrue(ChatsViewModel.State(hasSentRequests = true).showsRequestsMenu) - assertFalse(ChatsViewModel.State(hasSentRequests = true).needsDecision) - } - @Test fun `sent requests are newest first and skip a group not fetched yet`() { val requested = mapOf( From af73b5a71773c013122e0129c5631227541ed676 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 22:13:37 -0400 Subject: [PATCH 16/25] feat(groups): show a private group's posters as Members until roster reads open GetRoster is off and SampleChatters is denied for private groups, so the Members grid was hidden. Until the roster loads, it shows the members this device knows: the host, then the viewer, then everyone who has posted, newest first, named from the profiles on the device. Anyone without a profile is left out. The grid has no count while it shows this list, since there is no full list to open. A loaded roster replaces it. --- .../app/messenger/internal/ChatViewModel.kt | 40 +++++++++++++ .../screens/profile/GroupProfileScreen.kt | 14 +++-- .../screens/profile/GroupProfileState.kt | 25 ++++++++ .../profile/KnownPrivateGroupMembersTest.kt | 59 +++++++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/KnownPrivateGroupMembersTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 5c219c69c3..40badb77e7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -69,6 +69,8 @@ import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequireme import com.flipcash.app.messenger.internal.screens.profile.GroupProfileStanding import com.flipcash.app.messenger.internal.screens.profile.JoinRequestCall import com.flipcash.app.messenger.internal.screens.profile.LobbyCall +import com.flipcash.app.messenger.internal.screens.profile.knownPrivateGroupMembers +import com.flipcash.app.messenger.internal.screens.profile.postersNewestFirst import com.flipcash.app.messenger.internal.screens.profile.privateGroupMembers import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin @@ -491,6 +493,11 @@ internal class ChatViewModel @AssistedInject constructor( val joinRequests: List = emptyList(), /** A private group's full roster, creator first, for its Members grid and list. */ val members: List = emptyList(), + /** + * A private group's creator, the viewer and its posters, from this device: the Members + * grid's stand-in while the server won't list [members]. + */ + val knownMembers: List = emptyList(), /** Approve or Deny calls out, by the requester's user id, so only that row waits. */ val joinRequestCalls: Map = emptyMap(), /** @@ -802,6 +809,9 @@ internal class ChatViewModel @AssistedInject constructor( /** A private group's roster from [GroupRosters], already in display order. */ data class OnMembersResolved(val members: List) : Event + /** A private group's members as this device knows them, for the grid until the roster loads. */ + data class OnKnownMembersResolved(val members: List) : Event + /** The Members list came on screen: refetch, since a roster read can lag the stream. */ data object MembersOpened : Event data class OnProfileStandingResolved(val standing: GroupProfileStanding?) : Event @@ -2664,6 +2674,35 @@ internal class ChatViewModel @AssistedInject constructor( .onEach { dispatchEvent(it) } .launchIn(viewModelScope) + // Until roster reads open (GetRoster off, SampleChatters DENIED for a private group), the + // grid draws who this device knows: the creator, the viewer and everyone who has posted. + stateFlow.map { state -> + (state.subject as? ChatSubject.Group) + ?.takeIf { it.isPrivate && it.isMember == true } + ?.let { it.chatId to it.creator } + } + .distinctUntilChanged() + .flatMapLatest { group -> + if (group == null) { + flowOf(Event.OnKnownMembersResolved(emptyList())) + } else { + val (chatId, creator) = group + val selfId = userManager.accountId + combine( + chatCoordinator.observeMessages(chatId).map(::postersNewestFirst).distinctUntilChanged(), + senderProfiles.filterNotNull(), + ) { posters, profiles -> + (listOfNotNull(creator, selfId) + posters) + .filter { profiles[it.hexEncodedString()] == null } + .forEach(chatCoordinator::requestSenderProfile) + Event.OnKnownMembersResolved(knownPrivateGroupMembers(creator, selfId, posters, profiles)) + } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + merge( eventFlow.filterIsInstance(), eventFlow.filterIsInstance(), @@ -3437,6 +3476,7 @@ internal class ChatViewModel @AssistedInject constructor( is Event.OnHostNameResolved -> { state -> state.copy(hostName = event.name) } is Event.OnChattersLoaded -> { state -> state.copy(chatters = event.chatters) } is Event.OnMembersResolved -> { state -> state.copy(members = event.members) } + is Event.OnKnownMembersResolved -> { state -> state.copy(knownMembers = event.members) } Event.MembersOpened -> { state -> state } is Event.OnProfileStandingResolved -> { state -> state.copy(profileStanding = event.standing) } is Event.OnRuleTokensResolved -> { state -> state.copy(ruleTokens = event.tokens) } 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 e978d977f1..9e20349caf 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 @@ -293,8 +293,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { } // A private group's members see its roster instead of a sample: creator first, // then the newest joins, with the count opening the full list (node 11210:68). - val members = remember(state.members, unknownName) { - state.members.take(MembersGridLimit).map { member -> + // Until the server lists the roster, the grid shows who this device knows, + // without a count: there is no full list to open. + val hasRoster = state.members.isNotEmpty() + val gridMembers = if (hasRoster) state.members else state.knownMembers + val members = remember(gridMembers, unknownName) { + gridMembers.take(MembersGridLimit).map { member -> val profile = member.userProfile ChattingGridItem( userId = member.userId, @@ -315,10 +319,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { .padding(top = CodeTheme.dimens.staticGrid.x6), chatters = members, title = stringResource(R.string.title_members), - count = (group?.memberCount ?: state.members.size.toLong()).toString(), + count = if (hasRoster) { + (group?.memberCount ?: state.members.size.toLong()).toString() + } else null, onShowAll = { flowNavigator.navigateTo(ChatStep.Members) }, onOpen = { item -> - val member = state.members.firstOrNull { it.userId == item.userId } + val member = gridMembers.firstOrNull { it.userId == item.userId } ?: return@GroupChattingGrid flowNavigator.navigateTo( ChatStep.Profile( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt index 86575bee83..704a4a7ba8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal.screens.profile +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.SampledChatter @@ -15,6 +17,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.solana.keys.Mint +import com.getcode.utils.hexEncodedString import java.math.BigDecimal import java.math.RoundingMode import kotlin.time.Duration @@ -298,6 +301,28 @@ internal fun privateGroupMembers(members: List, creator: ID?): List< return host + rest.sortedByDescending { it.joinedAt } } +/** The distinct senders in [messages], the most recent poster first. */ +internal fun postersNewestFirst(messages: List): List = messages + .sortedByDescending { it.timestamp } + .mapNotNull { it.senderId } + .distinct() + +/** + * A private group's members as this device knows them, for its Members grid while the server + * won't list the roster: the creator, then the viewer, then everyone who has posted, newest + * first, each once. Anyone [profiles] can't name is left out rather than shown unnamed. + */ +internal fun knownPrivateGroupMembers( + creator: ID?, + selfId: ID?, + posters: List, + profiles: Map, +): List = (listOfNotNull(creator, selfId) + posters) + .distinct() + .mapNotNull { userId -> + profiles[userId.hexEncodedString()]?.let { ChatMember(userId, it, pointers = emptyList()) } + } + /** A lobby call in flight from a private group's profile. */ enum class LobbyCall { Request, Cancel } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/KnownPrivateGroupMembersTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/KnownPrivateGroupMembersTest.kt new file mode 100644 index 0000000000..3e9092ab1e --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/KnownPrivateGroupMembersTest.kt @@ -0,0 +1,59 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMessage +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class KnownPrivateGroupMembersTest { + + private val host: ID = listOf(1) + private val me: ID = listOf(2) + private val poster: ID = listOf(3) + private val stranger: ID = listOf(4) + + private fun profiles(vararg ids: ID): Map = + ids.associate { it.hexEncodedString() to mockk(relaxed = true) } + + private fun message(id: Long, sender: ID?) = ChatMessage( + messageId = id, + senderId = sender, + content = emptyList(), + timestamp = Instant.fromEpochSeconds(id), + unreadSeq = 0, + ) + + @Test + fun `posters are distinct, newest first, skipping messages with no sender`() { + val alice: ID = listOf(10) + val bob: ID = listOf(11) + val messages = listOf(message(1, alice), message(2, bob), message(3, null), message(4, alice)) + assertEquals(listOf(alice, bob), postersNewestFirst(messages)) + } + + @Test + fun `host first, then the viewer, then posters, each once`() { + val members = knownPrivateGroupMembers( + creator = host, + selfId = me, + posters = listOf(poster, host, me), + profiles = profiles(host, me, poster), + ) + assertEquals(listOf(host, me, poster), members.map { it.userId }) + } + + @Test + fun `anyone without a profile is left out`() { + val members = knownPrivateGroupMembers( + creator = null, + selfId = me, + posters = listOf(stranger), + profiles = profiles(me), + ) + assertEquals(listOf(me), members.map { it.userId }) + } +} From 874e2fa1d9ec3efd1e0a5f9436ee9a9273a6f191 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:06:08 -0400 Subject: [PATCH 17/25] fix(chats): match the Chats overflow menu to group info and the empty Requests screen to iOS --- .../core/src/main/res/values/strings.xml | 3 +- .../com/flipcash/app/tipping/ChatsScreen.kt | 46 +++++++++++-------- .../flipcash/app/tipping/RequestsScreen.kt | 29 +++++++++--- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4cac077803..c0f7f91674 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1138,7 +1138,8 @@ Requests Waiting on you Sent - No pending requests + No requests right now + Requests to join your private groups, and the ones you send, show up here %1$d person is waiting %1$d people are waiting diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index aa56d6c0d1..d5e8aff1f7 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,6 +1,10 @@ package com.flipcash.app.tipping import androidx.compose.animation.AnimatedVisibility +import androidx.compose.ui.graphics.Color +import com.getcode.ui.components.CircularIconButton +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image @@ -530,32 +534,34 @@ private fun RequestsMenu(needsDecision: Boolean, onRequests: () -> Unit) { } else { stringResource(R.string.action_moreProfileActions) } - Box { - // Pulled in from the corner so the dot sits just above and right of the top dot, as a - // badge on the glyph rather than off the button's padding. - WithNotificationDot(isShown = needsDecision, offset = DpOffset((-3).dp, (-1).dp)) { - Icon( - modifier = Modifier - .clip(CircleShape) - .clickable { expanded = true } - .clearAndSetSemantics { - contentDescription = description - role = Role.Button - } - .testTag("chats_overflow_menu") - .padding(CodeTheme.dimens.grid.x1) - .size(CodeTheme.dimens.staticGrid.x5), - imageVector = Icons.Rounded.MoreVert, - contentDescription = null, - tint = CodeTheme.colors.textMain, - ) + val density = LocalDensity.current + var anchorHeight by remember { mutableStateOf(0.dp) } + Box(modifier = Modifier.onSizeChanged { anchorHeight = with(density) { it.height.toDp() } }) { + // The group info screen's ⋯ button and menu, so the two overflow menus look the same. + WithNotificationDot(isShown = needsDecision, offset = DpOffset(0.dp, 0.dp)) { + CircularIconButton( + modifier = Modifier.clearAndSetSemantics { + contentDescription = description + role = Role.Button + }, + onClick = { expanded = true }, + testTag = "chats_overflow_menu", + ) { size -> + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } } - // Same surface as the profile's overflow menu. + // Opens over the button, as the group info menu does. DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, containerColor = CodeTheme.colors.brandLight, shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = -anchorHeight), ) { val requestsDescription = if (needsDecision) { stringResource(R.string.content_description_requestsWaiting) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt index be99afae22..b433b7b304 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -84,13 +86,26 @@ fun RequestsScreen() { .padding(bottom = padding.calculateBottomPadding()), contentAlignment = Alignment.Center, ) { - Text( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.title_noPendingRequests), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) + // Join Requests' empty state, without its Share Group button: there's no + // one group to share. + Column( + modifier = Modifier.padding(horizontal = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_noRequests), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.subtitle_noRequests), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } } } } From 5c895bc621dda5195fdb76ab98fb29b6e087ab63 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:14:49 -0400 Subject: [PATCH 18/25] fix(groups): keep a member's group key when storing its self-wrapped copy fails --- .../com/flipcash/services/chat/GroupKeyEnvelopes.kt | 6 ++++-- .../com/flipcash/services/chat/GroupKeyEnvelopesTest.kt | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt index 5cb5fb3b10..594726e814 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -118,15 +118,17 @@ class GroupKeyEnvelopes @Inject constructor( val groupKey = runCatching { cipher.unwrapGroupKey(own, creatorPk, chatId.bytes, payload) } .getOrElse { return Result.failure(UntrustedEnvelope("Creator-wrapped envelope did not open", it)) } + // The self-wrapped copy only saves the next launch a trip through the creator's envelope, so + // failing to store it doesn't cost the key that just opened. val selfWrapped = runCatching { cipher.wrapGroupKey(own, own.publicKey, chatId.bytes, groupKey).toKeyEnvelope() - }.getOrElse { return Result.failure(it) } + }.getOrElse { return Result.success(groupKey) } return chatController.setKeyEnvelope(chatId, selfWrapped).fold( onSuccess = { Result.success(groupKey) }, onFailure = { error -> // Another device re-wrapped first; its envelope stands, so read that one. if (error is SetKeyEnvelopeError.AlreadySet && !retried) fetchKey(chatId, own, retried = true) - else Result.failure(error) + else Result.success(groupKey) }, ) } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt index 67475103fb..8ce70c32b2 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -124,6 +124,15 @@ class GroupKeyEnvelopesTest { assertContentEquals(memberPk + memberPk + groupKey, rewrapped.captured.ciphertext) } + @Test + fun `a failed re-wrap still returns the key`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(creatorKeys, memberPk, creatorId)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(IOException("offline")) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + } + @Test fun `the key is fetched once`() = runTest { coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(memberKeys, memberPk, memberId)) From 6a56fa5be11a780a9033b76ea5bfd9e31fb588ad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:16:24 -0400 Subject: [PATCH 19/25] fix(notifications): show a private group message's text in its push The server can't read a private group message, so its push body is a stand-in. Open it on the device as DMs already do; unencrypted group messages open to nothing and keep the server's body. --- .../com/flipcash/app/notifications/NotificationService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 5e8bde3782..49f3a67e3c 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -478,9 +478,10 @@ class NotificationService : FirebaseMessagingService(), ?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) } ?: NotificationCompat.MessagingStyle(selfPerson) - // The server can't read an end-to-end encrypted DM, so its body is a stand-in. The opened - // text is the message itself, with no sender prefix to strip. - val opened = if (!styling.isGroupConversation) openPushedMessage(chatId, metadata) else null + // The server can't read an end-to-end encrypted DM or private group, so its body is a + // stand-in. The opened text is the message itself, with no sender prefix to strip; a group + // line takes its sender from the Person. Unencrypted messages open to null and keep the body. + val opened = openPushedMessage(chatId, metadata) style.addMessage(opened ?: planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) // After the extract above, not before: a re-post rebuilds the style from the notification From 734eb62e49e02bfe77b3a90f0e017c051e76fa90 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:16:59 -0400 Subject: [PATCH 20/25] fix(groups): mark messages under an untrusted group key unsupported A key wrapped by someone other than the viewer or the host never opens, so asking the sender to resend can't help. Show the update-app hint, as iOS does. --- .../app/persistence/sources/IncomingMessageOpenerTest.kt | 4 ++-- .../kotlin/com/flipcash/services/chat/GroupContentCrypto.kt | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt index 87e4ce3d36..5d808ede08 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt @@ -178,12 +178,12 @@ class IncomingMessageOpenerTest { } @Test - fun `a private group message under an untrusted envelope is undecryptable`() = runTest { + fun `a private group message under an untrusted envelope is unsupported`() = runTest { val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() groupKeyResult = Result.failure(UntrustedEnvelope("wrapped by a stranger")) assertEquals( - MessageEncryption.Undecryptable(UndecryptableReason.Authentication), + MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), open(message(sealed)).single().encryption, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt index e5bc3436f6..d4b40f982c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt @@ -60,8 +60,9 @@ class GroupContentCrypto @Inject constructor( } senderId ?: return OpenedContent.Undecryptable(UndecryptableReason.Authentication) val groupKey = keys.key(chatId).getOrElse { cause -> + // No resend opens a key wrapped by a stranger, so this matches iOS's "update the app". return if (cause is UntrustedEnvelope) { - OpenedContent.Undecryptable(UndecryptableReason.Authentication) + OpenedContent.Undecryptable(UndecryptableReason.Unsupported) } else { OpenedContent.KeyPending } From 3c505e8d9ddedefd8b9667c907b9e46f1fe9dc32 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:18:11 -0400 Subject: [PATCH 21/25] fix(chat): wait for the key when a sealed photo's chat isn't stored yet ChatPhotoLoader picked the DM path whenever the chat's row was missing, so a private group photo opened against an arbitrary member and failed for good. An unstored chat now reports KeyPending, which retries. --- .../shared/chat/media/ChatPhotoLoader.kt | 4 +++- .../shared/chat/media/ChatPhotoLoaderTest.kt | 23 +++++++++++++++++-- .../sources/ChatMetadataDataSource.kt | 9 +++++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt index 85abcb3162..aec7b4359d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt @@ -104,7 +104,9 @@ class ChatPhotoLoader internal constructor( suspend fun open(photo: ChatPhoto, downloaded: ByteArray): ByteArray { if (!photo.sealed) return downloaded val expected = photo.rendition.blob?.sizeBytes ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.Length) - if (metadata.isPrivate(photo.chatId)) { + // A chat not stored yet can't say which key opens it; guessing DM would fail a group photo for good. + val isPrivate = metadata.privacyOf(photo.chatId) ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) + if (isPrivate) { return groupCrypto.openBlob( chatId = photo.chatId, senderId = photo.senderId, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt index 8c0d95f1d0..c94c0f6f7c 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt @@ -4,6 +4,7 @@ import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.OpenedBlob import com.flipcash.services.controllers.BlobStorageController import com.flipcash.services.models.UserProfile @@ -42,8 +43,9 @@ class ChatPhotoLoaderTest { ChatMember(it, UserProfile(displayName = "n", socialAccounts = emptyList(), phoneNumber = null, email = null), emptyList()) } } - private val metadata = mockk { coEvery { isPrivate(any()) } returns false } - private val loader = ChatPhotoLoader(storage, crypto, user, members, mockk(), metadata) { now } + private val groupCrypto = mockk() + private val metadata = mockk { coEvery { privacyOf(any()) } returns false } + private val loader = ChatPhotoLoader(storage, crypto, user, members, groupCrypto, metadata) { now } private fun metadata(url: String, expiresAt: Instant? = null) = BlobMetadata( mimeType = "image/jpeg", sizeBytes = 3, downloadUrl = url, expiresAtMillis = expiresAt?.toEpochMilliseconds(), @@ -111,4 +113,21 @@ class ChatPhotoLoaderTest { val failure = assertFailsWith { loader.open(photo("u", sealed = true), byteArrayOf(1)) } assertEquals(BlobOpenFailure.Authentication, failure.reason) } + + @Test + fun `a sealed photo in a private group opens with the group key`() = runTest { + coEvery { metadata.privacyOf(chatId) } returns true + coEvery { groupCrypto.openBlob(chatId, peer, blobId.bytes, 3, any()) } returns + OpenedBlob.Plaintext(byteArrayOf(4, 5, 6)) + + assertEquals(listOf(4, 5, 6), loader.open(photo("u", sealed = true), byteArrayOf(1)).toList()) + } + + @Test + fun `a sealed photo in a chat not stored yet waits for its key`() = runTest { + coEvery { metadata.privacyOf(chatId) } returns null + + val failure = assertFailsWith { loader.open(photo("u", sealed = true), byteArrayOf(1)) } + assertEquals(BlobOpenFailure.KeyPending, failure.reason) + } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index f9ecc49b47..d089917ac2 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -12,7 +12,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -159,8 +159,11 @@ class ChatMetadataDataSource @Inject constructor( } /** Whether [chatId] is a stored private group, whose content is sealed under the chat key. */ - suspend fun isPrivate(chatId: ChatId): Boolean = - observeById(chatId).first()?.isPrivate == true + suspend fun isPrivate(chatId: ChatId): Boolean = privacyOf(chatId) == true + + /** Whether [chatId] is a private group, or null when it is not stored on this device. */ + suspend fun privacyOf(chatId: ChatId): Boolean? = + observeById(chatId).firstOrNull()?.isPrivate /** [chatId]'s group name, or null when the chat is a DM or is not stored on this device. */ suspend fun getTitle(chatId: ChatId): String? = From e8b9e9cc5fc730af75c0622817563c2ca59e4253 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:20:08 -0400 Subject: [PATCH 22/25] test(groups): cover sealing, sending and pushes in private groups GroupContentCrypto had no direct test, and the send and push tests only stubbed it out. Cover text and photo sends under the group key, a send with no key, group push bodies, and the chat, sender and blob-id binding. --- .../chat/MessagingPushDecryptionTest.kt | 24 ++++- .../chat/MessagingSendEncryptionTest.kt | 73 ++++++++++++++- .../services/chat/GroupContentCryptoTest.kt | 92 +++++++++++++++++++ 3 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt index a57de351b7..c1fe7fd242 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -5,6 +5,8 @@ import com.flipcash.app.persistence.sources.IncomingMessageOpener import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeySource import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.chat.ChatId @@ -25,7 +27,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.time.Instant -/** What a DM push shows: the opened plaintext, or `null` so the server's body stays. */ +/** What a DM or private group push shows: the opened plaintext, or `null` so the server's body stays. */ class MessagingPushDecryptionTest { private val chatId = ChatId(ByteArray(32) { 7 }) @@ -43,6 +45,9 @@ class MessagingPushDecryptionTest { private val selfSide = Keys(selfKeys, peerKeys.publicKey) private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val groupKey = ByteArray(32) { 5 } + private var groupKeyResult: Result = Result.success(groupKey) + private val group = GroupContentCrypto(FakeChatCipher, GroupKeySource { groupKeyResult }) private val messageDataSource = mockk(relaxed = true) private val messagingController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) { @@ -60,7 +65,7 @@ class MessagingPushDecryptionTest { stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), senderResolver = mockk(relaxed = true), - incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide), mockk()), + incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide), group), ) private suspend fun sealedFromPeer(content: MessageContent) = @@ -157,4 +162,19 @@ class MessagingPushDecryptionTest { fun `nothing to open without a message or its id`() = runTest { assertNull(delegate.openPushedMessage(chatId, message = null, messageId = null)) } + + @Test + fun `a private group message shows its plaintext`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group hello")).getOrThrow() + + assertEquals("group hello", delegate.openPushedMessage(chatId, message(sealed), messageId = null)) + } + + @Test + fun `a private group message keeps the server's body until the key is available`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group hello")).getOrThrow() + groupKeyResult = Result.failure(IOException("offline")) + + assertNull(delegate.openPushedMessage(chatId, message(sealed), messageId = null)) + } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 74239c1bcb..b7a46fd77c 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -9,6 +9,9 @@ import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.OpenedBlob import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.chat.OpenedContent import com.flipcash.services.controllers.ChatController @@ -73,6 +76,10 @@ class MessagingSendEncryptionTest { private val keys = Keys(selfKeys, peerKeys.publicKey) private val peerCrypto = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val groupKey = ByteArray(32) { 5 } + private var groupKeyResult: Result = Result.success(groupKey) + private val groupCrypto = GroupContentCrypto(FakeChatCipher, GroupKeySource { groupKeyResult }) + private val controller = mockk(relaxed = true) private val chatController = mockk(relaxed = true) private val messages = mockk(relaxed = true) @@ -82,7 +89,12 @@ class MessagingSendEncryptionTest { every { it.accountId } returns selfId } - private fun chat(type: ChatType = ChatType.CONTACT_DM, useE2ee: Boolean = true, peer: ID = peerId) = + private fun chat( + type: ChatType = ChatType.CONTACT_DM, + useE2ee: Boolean = true, + peer: ID = peerId, + isPrivate: Boolean = false, + ) = ChatMetadata( chatId = chatId, type = type, @@ -90,6 +102,7 @@ class MessagingSendEncryptionTest { lastMessage = null, lastActivity = Instant.fromEpochSeconds(0), useE2ee = useE2ee, + isPrivate = isPrivate, ) private fun member(id: ID) = ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList()) @@ -130,7 +143,7 @@ class MessagingSendEncryptionTest { chatController = chatController, metadataDataSource = metadata, memberDataSource = members, - groupCrypto = mockk(), + groupCrypto = groupCrypto, ), ) } @@ -210,7 +223,7 @@ class MessagingSendEncryptionTest { chatController = chatController, metadataDataSource = metadata, memberDataSource = members, - groupCrypto = mockk(), + groupCrypto = groupCrypto, ) @Test @@ -451,4 +464,58 @@ class MessagingSendEncryptionTest { coVerify(exactly = 0) { controller.editMessage(any(), any(), any(), any()) } assertEquals(emptyMap(), delegate.observePendingMutations(chatId).first()) } + + private fun privateGroup() = chat(type = ChatType.GROUP, isPrivate = true) + + private suspend fun openAsGroupMember(wire: List): MessageContent { + val sealed = assertIs(wire.single()) + assertEquals(GroupContentCrypto.SCHEME_CHAT_KEY_XCHACHA20POLY1305, sealed.scheme) + return assertIs(groupCrypto.open(chatId, selfId, sealed)).content + } + + @Test + fun `text in a private group goes out sealed under the group key`() = runTest { + storedChat(privateGroup()) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello group", replyToMessageId = null).getOrThrow() + + assertEquals(MessageContent.Text("hello group"), openAsGroupMember(wires.single())) + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("hello group")), stored.captured.content) + } + + @Test + fun `a photo in a private group goes out sealed under the group key`() = runTest { + storedChat(privateGroup()) + + val result = outgoing().prepare(chatId, listOf(photo), blobSealedFor = chatId).getOrThrow() + + assertEquals(true, result.isSealed) + assertEquals(photo, openAsGroupMember(result.wire)) + } + + @Test + fun `a private group's photo bytes are sealed under the group key`() = runTest { + storedChat(privateGroup()) + val blobId = BlobId(ByteArray(16) { 7 }) + val bytes = byteArrayOf(1, 2, 3) + + val sealer = checkNotNull(outgoing().blobSealer(chatId).getOrThrow()) + val sealed = sealer.sealing(bytes).seal(blobId) + + val opened = groupCrypto.openBlob(chatId, selfId, blobId.bytes, bytes.size.toLong(), sealed) + assertEquals(bytes.toList(), assertIs(opened).bytes.toList()) + } + + @Test + fun `a private group send without the group key fails so it can be retried`() = runTest { + storedChat(privateGroup()) + groupKeyResult = Result.failure(IOException("offline")) + answerSends() + + assertEquals(true, delegate.sendMessage(chatId, "hello group", replyToMessageId = null).isFailure) + coVerify(exactly = 0) { controller.sendMessage(chatId, any(), any()) } + } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt new file mode 100644 index 0000000000..6b69ee92a8 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** Private-group content sealed under the chat key: bound to its chat, sender and blob id. */ +class GroupContentCryptoTest { + + private val chatId = ChatId(ByteArray(32) { 3 }) + private val otherChat = ChatId(ByteArray(32) { 4 }) + private val sender: ID = List(16) { 1 } + private val someoneElse: ID = List(16) { 2 } + private val blobId = ByteArray(16) { 7 } + + private var key: Result = Result.success(ByteArray(32) { 5 }) + private val crypto = GroupContentCrypto(FakeChatCipher, GroupKeySource { key }) + + private suspend fun sealText(text: String) = + crypto.seal(chatId, sender, MessageContent.Text(text)).getOrThrow() + + @Test + fun `text sealed under the chat key opens to itself`() = runTest { + val sealed = sealText("hi") + + assertEquals(GroupContentCrypto.SCHEME_CHAT_KEY_XCHACHA20POLY1305, sealed.scheme) + assertEquals(OpenedContent.Plaintext(MessageContent.Text("hi")), crypto.open(chatId, sender, sealed)) + } + + @Test + fun `a message claimed by another sender or chat doesn't open`() = runTest { + val sealed = sealText("hi") + + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Authentication), + crypto.open(chatId, someoneElse, sealed), + ) + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Authentication), + crypto.open(otherChat, sender, sealed), + ) + } + + @Test + fun `a key that can't be fetched leaves the message pending`() = runTest { + val sealed = sealText("hi") + key = Result.failure(IOException("offline")) + + assertEquals(OpenedContent.KeyPending, crypto.open(chatId, sender, sealed)) + } + + @Test + fun `a key wrapped by a stranger marks the message unsupported`() = runTest { + val sealed = sealText("hi") + key = Result.failure(UntrustedEnvelope("wrapped by a stranger")) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Unsupported), crypto.open(chatId, sender, sealed)) + } + + @Test + fun `sealing without the key fails`() = runTest { + key = Result.failure(IOException("offline")) + + assertTrue(crypto.seal(chatId, sender, MessageContent.Text("hi")).isFailure) + } + + @Test + fun `a photo's bytes open for the same blob and are refused for another`() = runTest { + val bytes = byteArrayOf(1, 2, 3) + val sealed = crypto.sealBlob(chatId, sender, blobId, bytes).getOrThrow() + + val opened = crypto.openBlob(chatId, sender, blobId, bytes.size.toLong(), sealed) + assertContentEquals(bytes, assertIs(opened).bytes) + + val wrongBlob = crypto.openBlob(chatId, sender, ByteArray(16) { 8 }, bytes.size.toLong(), sealed) + assertEquals(OpenedBlob.Failed(BlobOpenFailure.Authentication), wrongBlob) + } + + @Test + fun `a photo that opens to the wrong length is refused`() = runTest { + val sealed = crypto.sealBlob(chatId, sender, blobId, byteArrayOf(1, 2, 3)).getOrThrow() + + assertEquals(OpenedBlob.Failed(BlobOpenFailure.Length), crypto.openBlob(chatId, sender, blobId, 4, sealed)) + } +} From 9748fc548b8f83982aa6bffcaf131efa5580510f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:22:18 -0400 Subject: [PATCH 23/25] fix(groups): name a private group message's author in the resend hint --- .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 40badb77e7..a9d3d15a03 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1324,9 +1324,10 @@ internal class ChatViewModel @AssistedInject constructor( undecryptableHint = undecryptableHint( encryption = message.encryption, isFromSelf = message.isFromSelf, - // Only a DM is encrypted, so the sender of an incoming one is the - // participant. - senderName = stateFlow.value.participant?.name + // A group has no participant: its sender is this message's author, + // and a DM's is the participant. + senderName = sender?.displayName + ?: stateFlow.value.participant?.name ?: resources.getString(R.string.title_unnamedUser), ) ?: UndecryptableHint.UpdateApp, ) From d57ad4f1273b346d119dfb411db316870f504041 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 12 Oct 2026 23:31:11 -0400 Subject: [PATCH 24/25] fix(groups): open Join Requests from Requests without the group profile under it --- .../core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index d2269b7698..ecc9d73eac 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -363,13 +363,13 @@ sealed interface AppRoute : NavKey, Parcelable { // profile is the flow's root, so back returns to wherever the tap came from, and Open // Chat pushes the transcript on top of it. val openOnProfile: Boolean = false, - // Open on a private group's Join Requests, over its profile so back lands there. Set by - // the Requests screen's hosted rows. + // Open on a private group's Join Requests as the flow's root, so back returns to the + // Requests screen that set it, the way iOS does. val openOnJoinRequests: Boolean = false, ) : Messaging, FlowRoute { override val initialStack: List get() = when { - openOnJoinRequests -> listOf(ChatStep.GroupProfile, ChatStep.JoinRequests) + openOnJoinRequests -> listOf(ChatStep.JoinRequests) openOnProfile -> listOf(ChatStep.GroupProfile) else -> listOf(ChatStep.Conversation) } From 3916c034b0290a594a03cc73a5ce7e6b7f62bf96 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 13 Oct 2026 07:20:10 -0400 Subject: [PATCH 25/25] test(chat): pass a group crypto to the share gateway's outgoing encryption --- .../com/flipcash/shared/chat/share/ChatShareGatewayTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt index 6727aabc94..9fe67b80b7 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt @@ -12,6 +12,8 @@ import com.flipcash.app.persistence.sources.PendingMessage import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeySource import com.flipcash.services.chat.FakeChatCipher import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController @@ -114,6 +116,8 @@ class ChatShareGatewayTest { private val outgoing = DmOutgoingEncryption( policy = E2eePolicy(), crypto = ChatContentCrypto(FakeChatCipher, keys), + // Every chat shared to here is a DM or public group, so no group key is ever asked for. + groupCrypto = GroupContentCrypto(FakeChatCipher, GroupKeySource { error("no private groups") }), userManager = userManager, chatController = chatController, metadataDataSource = metadata,