From 7ebbe0e6884c4e29867acd6189be76abeb18cfe8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 19:38:19 -0400 Subject: [PATCH 01/13] feat(chat): persist the viewer's mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flipcash2 added per-viewer mute state, but chat_metadata had no column for it, so EventStreamDelegate acknowledged ViewerStateChanged and dropped it. Adds three columns behind database version 35 (auto-migrated): mute_until_epoch_ms, mute_forever, and viewer_state_version. A timed mute is stored as its deadline rather than as a muted flag, because it lapses with no server signal — only the deadline compared against the clock on read stays correct. MuteState.isActiveAt is that comparison, and is how anything should ask whether a chat is muted. Writes go through updateViewerStateIfNewer, gated on a strictly higher version. Stream delivery is unordered, so a mute and the unmute after it can land either way round and the version is what decides. The gate also makes the round trip a no-op: toMetadata carries the stored version, so metadata rebuilt from a row and pushed back through upsert cannot re-apply what is already there. Leaving a chat clears the mute and resets the version to zero, both on a successful leave and on a self MemberLeft from another device. The server drops the mute when you leave and sends nothing to say so, so a retained one would be waiting on the other side of a rejoin; resetting the version means the rejoin's first viewer state still applies. --- .../internal/delegates/EventStreamDelegate.kt | 9 +- .../internal/delegates/GroupFeedDelegate.kt | 9 + .../shared/chat/ViewerStateStreamTest.kt | 215 +++++ .../35.json | 842 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../app/persistence/dao/ChatMetadataDao.kt | 52 +- .../entities/ChatMetadataEntity.kt | 11 + .../persistence/dao/ChatMetadataDaoTest.kt | 95 ++ .../sources/ChatMetadataDataSource.kt | 24 + .../sources/mapper/chat/ChatEntityMapper.kt | 36 + .../mapper/chat/ChatEntityMapperTest.kt | 72 ++ .../services/models/chat/MuteExtensions.kt | 22 + .../services/models/chat/MuteLapseTest.kt | 58 ++ 13 files changed, 1440 insertions(+), 8 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteExtensions.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MuteLapseTest.kt 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 46f12a88b4..077dfc416b 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 @@ -360,11 +360,10 @@ class EventStreamDelegate @Inject constructor( ) } is MetadataUpdate.ViewerStateChanged -> { - // The receiving user's own chat state (currently just mute) changed. - // Persisting it needs a chat_metadata column/migration that belongs - // with the muting feature's storage work, not this contract sync, so - // the update is acknowledged here without a write. Wire this up to - // metadataDataSource once that column exists. + // Convergent, like the roster and reaction overlays below: applied by version + // rather than in arrival order, because the stream does not order deliveries + // and a mute and the unmute after it can land either way round. + metadataDataSource.updateViewerState(chatId, metaUpdate.viewerState) } } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index 55e6c6e2e7..e1b409ca68 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -150,10 +150,16 @@ class GroupFeedDelegate @Inject constructor( * The row is cleared first so the chat drops out of the list on the tap rather than a round * trip later, and put back if the call fails — the same optimistic shape as * [FeedSyncDelegate.setChatHidden], with the restore that a remote call needs. + * + * The viewer state goes on success, not optimistically: leaving clears the mute server-side + * and the server sends nothing to say so, so a retained mute would outlive the chat it + * belonged to and be waiting on the other side of a rejoin. A failed leave leaves it alone, + * which is what the restore above wants — the user is still in the chat, still muted. */ override suspend fun leave(chatId: ChatId): Result { metadataDataSource.setMembership(chatId, isMember = false) return chatController.leaveChat(chatId) + .onSuccess { metadataDataSource.clearViewerState(chatId) } .onFailure { trace(tag = TAG, message = "Leave failed for $chatId", type = TraceType.Error) metadataDataSource.setMembership(chatId, isMember = true) @@ -188,6 +194,9 @@ class GroupFeedDelegate @Inject constructor( is RosterChange.MemberLeft -> { if (change.userId != selfId) continue metadataDataSource.setMembership(chatId, isMember = false) + // Same reason as [leave]: the mute is gone server-side the moment you are out + // of the chat, whether you left from this device or another one. + metadataDataSource.clearViewerState(chatId) } } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt new file mode 100644 index 0000000000..a14cc7907d --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt @@ -0,0 +1,215 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatIdGenerator +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.utils.network.NetworkConnectivityListener +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * The viewer-state arm of the event stream: a `ViewerStateChanged` is stored rather than dropped. + * + * These assert the hand-off only. Which of two viewer states wins is decided by the version gate in + * the DAO, and is covered where that gate lives + * ([com.flipcash.app.persistence.dao.ChatMetadataDaoTest]) — the delegate hands every update over + * and lets the version sort them out, because it cannot know what else is in flight. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ViewerStateStreamTest { + + private val selfId = listOf(1, 2, 3) + private val chatId = ChatId("aabbccdd") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var metadataDataSource: ChatMetadataDataSource + private lateinit var coordinator: RealChatCoordinator + private lateinit var testDispatchers: TestDispatchers + + @Before + fun setUp() { + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(any(), any()) } returns + Result.failure(RuntimeException("not needed")) + + testDispatchers = TestDispatchers(TestCoroutineScheduler()) + + val stateHolder = ChatStateHolder() + val memberDataSource = mockk(relaxed = true) + val messagingController = mockk(relaxed = true) + metadataDataSource = mockk(relaxed = true) + val messageDataSource = mockk(relaxed = true) + + val feedDelegate = FeedSyncDelegate( + chatController = chatController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = stateHolder, + userManager = userManager, + ) + + val eventStreamDelegate = EventStreamDelegate( + eventStreamingController = eventStreamingController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + tokenCoordinator = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = mockk(relaxed = true), + exchange = mockk(relaxed = true), + ) + + val messagingDelegate = MessagingDelegate( + chatController = chatController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) + + val dmChatResolverDelegate = DmChatResolverDelegate( + chatIdGenerator = ChatIdGenerator(), + userManager = userManager, + contactDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + ) + + coordinator = RealChatCoordinator( + feedDelegate = feedDelegate, + eventStreamDelegate = eventStreamDelegate, + dmChatResolverDelegate = dmChatResolverDelegate, + messagingDelegate = messagingDelegate, + stateHolder = stateHolder, + userManager = userManager, + networkObserver = mockk(relaxed = true), + dispatchers = testDispatchers, + groupFeedDelegate = mockk(relaxed = true), + ) + } + + private suspend fun triggerCollection() { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + } + + private fun viewerStateUpdate(viewerState: ViewerState) = ChatUpdate( + chatId = chatId, + metadataUpdates = listOf(MetadataUpdate.ViewerStateChanged(viewerState)), + ) + + @Test + fun `a timed mute from the stream is stored`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + val viewerState = ViewerState( + mute = MuteState.Until(Instant.fromEpochSeconds(2_000)), + version = 4, + ) + + chatUpdatesChannel.send(viewerStateUpdate(viewerState)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateViewerState(chatId, viewerState) } + coordinator.teardown() + } + + @Test + fun `an indefinite mute from the stream is stored`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + val viewerState = ViewerState(mute = MuteState.Forever, version = 7) + + chatUpdatesChannel.send(viewerStateUpdate(viewerState)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateViewerState(chatId, viewerState) } + coordinator.teardown() + } + + /** + * An unmute arrives as a viewer state carrying no mute, not as an absent update: the version it + * carries is what makes it beat the mute it replaces, so it has to be written like any other. + */ + @Test + fun `an unmute from the stream is stored at its own version`() = + runTest(testDispatchers.dispatcher) { + triggerCollection() + val viewerState = ViewerState(mute = null, version = 8) + + chatUpdatesChannel.send(viewerStateUpdate(viewerState)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateViewerState(chatId, viewerState) } + coordinator.teardown() + } + + @Test + fun `an update carrying no viewer state leaves the stored one alone`() = + runTest(testDispatchers.dispatcher) { + triggerCollection() + + chatUpdatesChannel.send( + ChatUpdate( + chatId = chatId, + metadataUpdates = listOf( + MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(2_000)), + ), + ), + ) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 0) { metadataDataSource.updateViewerState(any(), any()) } + coordinator.teardown() + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json new file mode 100644 index 0000000000..c9b3f163f9 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json @@ -0,0 +1,842 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "996b76c50f037d1cd142131808e343d5", + "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, `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, 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": "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" + } + ], + "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, 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" + } + ], + "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": "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" + ] + } + } + ], + "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, '996b76c50f037d1cd142131808e343d5')" + ] + } +} \ 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 115bff2173..0197e15636 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 @@ -95,8 +95,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 31, to = 32, spec = FlipcashDatabase.Migration31To32::class), AutoMigration(from = 32, to = 33), // chat_messages index on (chat_id_hex, timestamp_epoch_ms) AutoMigration(from = 33, to = 34), // chat_metadata group columns: title, picture, roster, rules, membership + AutoMigration(from = 34, to = 35), // chat_metadata viewer state: mute deadline, forever flag, version ], - version = 34, + version = 35, ) @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 b680193d06..c4ec537bf1 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 @@ -74,8 +74,9 @@ interface ChatMetadataDao { /** * Overwrites only the columns the server owns. `latest_event_sequence` and * `analytics_counted_through` are client-owned watermarks that no server payload - * carries, so they are deliberately absent here. The roster columns are absent too — - * they are versioned, and go through [updateRosterIfNewer]. + * carries, so they are deliberately absent here. The roster and viewer-state columns + * are absent too — they are versioned, and go through [updateRosterIfNewer] and + * [updateViewerStateIfNewer]. */ @Query( "UPDATE chat_metadata SET chat_type = :chatType, " + @@ -114,6 +115,47 @@ interface ChatMetadataDao { ) suspend fun updateRosterIfNewer(chatIdHex: String, memberCount: Long, rosterVersion: Long) + /** The viewer-state version already applied to [chatIdHex], or null when the chat is not stored. */ + @Query("SELECT viewer_state_version FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getViewerStateVersion(chatIdHex: String): Long? + + /** + * Applies a viewer state only when it is strictly newer than the stored one. + * + * `ViewerState.version` is what makes the state converge: stream delivery is not ordered, so + * a mute and the unmute that followed it can arrive either way round, and applying the last + * one to land would leave the wrong answer stored. Guarding on the version is also what stops + * a [ChatMetadataEntity] rebuilt from this row — which reports the version it was read at — + * from re-applying itself on the way back through an upsert. + * + * Both mute columns are written together so a switch between the two shapes clears the other. + */ + @Query( + "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + + "mute_forever = :muteForever, viewer_state_version = :version " + + "WHERE chat_id_hex = :chatIdHex AND :version > viewer_state_version" + ) + suspend fun updateViewerStateIfNewer( + chatIdHex: String, + muteUntilEpochMs: Long?, + muteForever: Boolean, + version: Long, + ) + + /** + * Drops the viewer state, version and all. + * + * Leaving a chat clears the mute server-side, and the server sends nothing to say so. The + * version goes back to zero with it: a rejoin starts the server's numbering over, so a + * retained version would make the gate reject the new state and the old mute would survive a + * chat the user has left and re-entered. + */ + @Query( + "UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " + + "viewer_state_version = 0 WHERE chat_id_hex = :chatIdHex" + ) + suspend fun clearViewerState(chatIdHex: String) + /** * Inserts a new chat, or refreshes an existing one's server-owned columns in place. * @@ -143,6 +185,12 @@ interface ChatMetadataDao { memberCount = entity.memberCount, rosterVersion = entity.rosterVersion, ) + updateViewerStateIfNewer( + chatIdHex = entity.chatIdHex, + muteUntilEpochMs = entity.muteUntilEpochMs, + muteForever = entity.muteForever, + version = entity.viewerStateVersion, + ) } @Transaction 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 940404a8b0..21639fa2e4 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 @@ -44,4 +44,15 @@ data class ChatMetadataEntity( // should be readable in the table rather than implied by whether a row exists. @ColumnInfo(name = "is_member", defaultValue = "1") val isMember: Boolean = true, + // The viewer's own mute, and the version that decides whether a viewer-state write applies. + // A timed mute is stored as its deadline rather than as a muted flag: it lapses with no + // server signal, so only the deadline compared against the clock on read stays correct. + // Exactly two mute shapes exist — until a deadline, or forever — so the two columns are + // never both set. + @ColumnInfo(name = "mute_until_epoch_ms") + val muteUntilEpochMs: Long? = null, + @ColumnInfo(name = "mute_forever", defaultValue = "0") + val muteForever: Boolean = false, + @ColumnInfo(name = "viewer_state_version", defaultValue = "0") + val viewerStateVersion: Long = 0, ) 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 120bdf7559..576451dee1 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 @@ -58,6 +58,9 @@ class ChatMetadataDaoTest { rosterVersion: Long = 0, rulesJson: ChatRulesSerialized? = null, isMember: Boolean = true, + muteUntilEpochMs: Long? = null, + muteForever: Boolean = false, + viewerStateVersion: Long = 0, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -72,6 +75,9 @@ class ChatMetadataDaoTest { rosterVersion = rosterVersion, rulesJson = rulesJson, isMember = isMember, + muteUntilEpochMs = muteUntilEpochMs, + muteForever = muteForever, + viewerStateVersion = viewerStateVersion, ) @Test @@ -186,6 +192,95 @@ class ChatMetadataDaoTest { assertEquals(4L, stored?.rosterVersion) } + @Test + fun `a newer viewer state version replaces the mute columns`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 4)) + + dao.updateViewerStateIfNewer( + chatIdHex = CHAT_HEX, + muteUntilEpochMs = null, + muteForever = false, + version = 5, + ) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertNull(stored.muteUntilEpochMs) + assertEquals(false, stored.muteForever) + assertEquals(5L, stored.viewerStateVersion) + } + + /** + * The stream does not order deliveries, so a mute and the unmute that followed it can arrive + * either way round. Applying whichever landed last would leave the wrong answer stored, which + * is why the version and not the arrival decides. + */ + @Test + fun `an older viewer state version leaves the mute alone`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 5) + + // The mute this supersedes, arriving late. + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, version = 4) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(true, stored.muteForever) + assertNull(stored.muteUntilEpochMs) + assertEquals(5L, stored.viewerStateVersion) + } + + /** + * The same gate an upsert goes through. A `ChatMetadata` rebuilt from this row reports the + * version it was read at, so pushing it back must be a no-op rather than a re-application. + */ + @Test + fun `an upsert at the stored version does not rewrite the mute`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) + + dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = null, muteForever = false, viewerStateVersion = 7)) + + assertEquals(true, dao.getById(CHAT_HEX)?.muteForever) + } + + @Test + fun `switching to an indefinite mute clears the deadline`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 1)) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 2) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertNull(stored.muteUntilEpochMs) + assertEquals(true, stored.muteForever) + } + + /** + * Leaving clears the mute server-side and says nothing about it, and a rejoin starts the + * server's versioning over — so a version left behind would make the gate reject the new + * state and the old mute would survive the chat it belonged to. + */ + @Test + fun `clearing the viewer state drops the mute and its version`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) + + dao.clearViewerState(CHAT_HEX) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(false, stored.muteForever) + assertNull(stored.muteUntilEpochMs) + assertEquals(0L, stored.viewerStateVersion) + + // A rejoin's first state numbers from the bottom again, and must still apply. + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, version = 1) + assertEquals(9_000L, dao.getById(CHAT_HEX)?.muteUntilEpochMs) + } + + @Test + fun `the viewer state version reads back, and is null for a chat that is not there`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) + + assertEquals(7L, dao.getViewerStateVersion(CHAT_HEX)) + assertEquals(null, dao.getViewerStateVersion(OTHER_HEX)) + } + /** * One ordering across every type is the whole point of the merged feed: a group that belongs * between two DMs has to come back between them, not after all of them. 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 ffe0ff3fcf..1c26bbb3bb 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 @@ -9,6 +9,7 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map @@ -65,6 +66,29 @@ class ChatMetadataDataSource @Inject constructor( ) } + /** The viewer-state version already applied to [chatId]; 0 when the chat is not stored yet. */ + suspend fun getViewerStateVersion(chatId: ChatId): Long = + db?.chatMetadataDao()?.getViewerStateVersion(mapper.chatIdHex(chatId)) ?: 0L + + /** + * Stores [viewerState] if its version beats the stored one, and drops it otherwise. Stream + * delivery is unordered, so the newest state is the one with the highest version, not the one + * that arrived last. + */ + suspend fun updateViewerState(chatId: ChatId, viewerState: ViewerState) { + db?.chatMetadataDao()?.updateViewerStateIfNewer( + chatIdHex = mapper.chatIdHex(chatId), + muteUntilEpochMs = mapper.muteUntilEpochMs(viewerState.mute), + muteForever = mapper.isMuteForever(viewerState.mute), + version = viewerState.version, + ) + } + + /** Forgets the viewer state for [chatId], version included. Leaving a chat clears its mute. */ + suspend fun clearViewerState(chatId: ChatId) { + db?.chatMetadataDao()?.clearViewerState(mapper.chatIdHex(chatId)) + } + /** The hex a [chatId] is keyed by. Callers comparing against [getChatIdsOfType] need it. */ fun chatIdHex(chatId: ChatId): String = mapper.chatIdHex(chatId) 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 a68f3587d1..f83d371038 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 @@ -31,10 +31,12 @@ import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ClientMessageId import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -84,6 +86,9 @@ class ChatEntityMapper @Inject constructor() { rosterVersion = metadata.rosterSummary.version, rulesJson = metadata.rules?.toSerialized(), isMember = isMember, + muteUntilEpochMs = muteUntilEpochMs(metadata.viewerState?.mute), + muteForever = isMuteForever(metadata.viewerState?.mute), + viewerStateVersion = metadata.viewerState?.version ?: 0, ) } @@ -109,9 +114,40 @@ class ChatEntityMapper @Inject constructor() { version = entity.rosterVersion, ), rules = entity.rulesJson?.toDomain(), + // Carried at its real version, unlike `latestEventSequence` above: the version is the + // thing the write gate compares, so a metadata rebuilt from this row and pushed back + // through an upsert is a no-op rather than a re-application of what is already there. + viewerState = entity.toViewerState(), ) } + /** The `mute_until_epoch_ms` column a [mute] writes; null for forever and for no mute. */ + fun muteUntilEpochMs(mute: MuteState?): Long? = + (mute as? MuteState.Until)?.until?.toEpochMilliseconds() + + /** The `mute_forever` column a [mute] writes. */ + fun isMuteForever(mute: MuteState?): Boolean = mute is MuteState.Forever + + /** + * The row's viewer state, or null when it holds nothing about the viewer. + * + * A lapsed deadline still reads back as a [MuteState.Until]; whether it is still in force is + * `isActiveAt(now)`'s answer, asked at render time. Dropping it here would make the row and + * the domain model disagree about what is stored, and a later write gate compare against a + * version whose state had been silently rewritten. + */ + private fun ChatMetadataEntity.toViewerState(): ViewerState? { + // Read into a local first: a smart cast will not hold across a module boundary. + val until = muteUntilEpochMs + val mute = when { + muteForever -> MuteState.Forever + until != null -> MuteState.Until(Instant.fromEpochMilliseconds(until)) + else -> null + } + if (mute == null && viewerStateVersion == 0L) return null + return ViewerState(mute = mute, version = viewerStateVersion) + } + // endregion // region ChatMessage 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 516ad68170..e08e768076 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 @@ -13,7 +13,9 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.ViewerState import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import org.junit.Assert.assertEquals @@ -194,6 +196,76 @@ class ChatEntityMapperTest { assertEquals(null, entity.rulesJson) } + @Test + fun `a timed mute is written as its deadline`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState( + mute = MuteState.Until(Instant.fromEpochSeconds(2_000)), + version = 3, + ) + ) + ) + + assertEquals(2_000_000L, entity.muteUntilEpochMs) + assertEquals(false, entity.muteForever) + assertEquals(3L, entity.viewerStateVersion) + } + + @Test + fun `an indefinite mute is written as the flag and no deadline`() { + val entity = mapper.toEntity( + groupMetadata().copy(viewerState = ViewerState(mute = MuteState.Forever, version = 3)) + ) + + assertEquals(null, entity.muteUntilEpochMs) + assertEquals(true, entity.muteForever) + } + + /** + * The deadline survives even once it is in the past. Whether the mute is still in force is + * `isActiveAt(now)`'s answer at render time, not something the mapper resolves on the way + * out — resolving it here would make the row and the model disagree about what is stored. + */ + @Test + fun `a lapsed mute still round-trips as its deadline`() { + val lapsed = Instant.fromEpochSeconds(1_500) + val entity = mapper.toEntity( + groupMetadata().copy(viewerState = ViewerState(mute = MuteState.Until(lapsed), version = 3)) + ) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals(MuteState.Until(lapsed), restored.viewerState?.mute) + assertEquals(3L, restored.viewerState?.version) + } + + /** + * Unlike `latestEventSequence` above, the version is carried back out: it is what the write + * gate compares, so a metadata rebuilt from a row and pushed back through an upsert has to + * fail the gate rather than re-apply itself over something newer. + */ + @Test + fun `the viewer state version survives the round trip`() { + val entity = mapper.toEntity( + groupMetadata().copy(viewerState = ViewerState(mute = MuteState.Forever, version = 9)) + ) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals(9L, restored.viewerState?.version) + assertEquals(MuteState.Forever, restored.viewerState?.mute) + } + + @Test + fun `a chat holding nothing about the viewer has no viewer state`() { + val entity = mapper.toEntity(metadata(latestEventSequence = 0)) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals(null, restored.viewerState) + } + private companion object { const val CHAT_HEX = "aabbccdd" } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteExtensions.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteExtensions.kt new file mode 100644 index 0000000000..5679b6e861 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteExtensions.kt @@ -0,0 +1,22 @@ +package com.flipcash.services.models.chat + +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Whether the mute is still in force at [now]. + * + * A [MuteState.Until] lapses with no server signal — the client owns the countdown — so the + * deadline is compared against the clock on every read. Resolving it once into a stored or + * rendered boolean would keep a chat muted past the moment it stopped being muted, until some + * unrelated round trip happened to refresh it. + */ +fun MuteState?.isActiveAt(now: Instant = Clock.System.now()): Boolean = when (this) { + null -> false + MuteState.Forever -> true + is MuteState.Until -> until > now +} + +/** Whether the chat is muted for the viewer at [now]. See [isActiveAt]. */ +fun ViewerState?.isMutedAt(now: Instant = Clock.System.now()): Boolean = + this?.mute.isActiveAt(now) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MuteLapseTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MuteLapseTest.kt new file mode 100644 index 0000000000..591199d0b8 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MuteLapseTest.kt @@ -0,0 +1,58 @@ +package com.flipcash.services.models.chat + +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Instant + +/** + * A timed mute lapses with no server signal — the client owns the countdown. Everything that + * reads a mute therefore asks whether it is in force *now*, rather than reading a boolean that + * someone resolved earlier and that has been wrong since the deadline passed. + */ +class MuteLapseTest { + + private val now = Instant.fromEpochSeconds(1_000) + + @Test + fun `a mute whose deadline has passed reads as unmuted`() { + val lapsed = MuteState.Until(Instant.fromEpochSeconds(999)) + + assertEquals(false, lapsed.isActiveAt(now)) + } + + @Test + fun `a mute whose deadline is still ahead reads as muted`() { + val live = MuteState.Until(Instant.fromEpochSeconds(1_001)) + + assertEquals(true, live.isActiveAt(now)) + } + + /** The deadline is the first instant the chat is audible again, not the last it is muted. */ + @Test + fun `a mute reads as lapsed at the deadline itself`() { + val atDeadline = MuteState.Until(now) + + assertEquals(false, atDeadline.isActiveAt(now)) + } + + @Test + fun `an indefinite mute stays in force`() { + assertEquals(true, MuteState.Forever.isActiveAt(now)) + } + + @Test + fun `no mute is not muted`() { + assertEquals(false, (null as MuteState?).isActiveAt(now)) + } + + @Test + fun `viewer state defers to the mute it carries`() { + val lapsed = ViewerState(mute = MuteState.Until(Instant.fromEpochSeconds(999)), version = 4) + val live = ViewerState(mute = MuteState.Until(Instant.fromEpochSeconds(1_001)), version = 4) + + assertEquals(false, lapsed.isMutedAt(now)) + assertEquals(true, live.isMutedAt(now)) + assertEquals(false, ViewerState(mute = null, version = 4).isMutedAt(now)) + assertEquals(false, (null as ViewerState?).isMutedAt(now)) + } +} From 684f2008ea2a40918bd37611671cf9fd785da375 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 19:52:32 -0400 Subject: [PATCH 02/13] feat(chat): mute a group from its profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mute RPCs were wired and the viewer state stored, but nothing asked for either. This adds the entry point, beside Leave Chat on the group profile. Muting takes a shape, so the row opens a sheet: 1 Hour, 8 Hours, 1 Week, Always. Nothing else, because the contract admits exactly two mute shapes — until a deadline, or forever — and every option here is one of the two. Unmuting is its own RPC rather than a zero-length mute, so it is its own row, shown in the mute row's place while a mute is actually in force. The set and the wording match what iOS shipped, taken from WhatsApp's sheet plus Telegram's one-hour option. Options name the state they produce ("1 Week") rather than the act, so the muted row reuses the same words in its readout: "Muted until 5:56 PM" — a time today, a weekday this week, a date beyond that. Without the deadline a timed mute reads exactly like a permanent one. Whether the chat is muted is asked per composition rather than stored: a timed mute lapses with nothing sent to say so, and rememberIsMuted waits out the remaining time so the row flips back to Mute while the screen is open. The sheet mints its deadline on the tap, not on open, so a sheet left sitting still means what it says. The mute call is not optimistic, unlike the leave. The stored viewer state is versioned and the version is the server's to mint; writing a guess would either lose to the echo of the call itself or shut a genuinely newer update out. The response goes through the same version gate the stream writes go through, so either arrival order lands on the same row. --- .../com/flipcash/app/core/chat/ChatStep.kt | 13 ++ .../core/src/main/res/values/strings.xml | 24 ++++ .../flipcash/app/messenger/ChatFlowScreen.kt | 26 +++- .../app/messenger/internal/ChatSubject.kt | 8 ++ .../app/messenger/internal/ChatViewModel.kt | 51 +++++++- .../app/messenger/internal/MuteOption.kt | 117 ++++++++++++++++++ .../internal/screens/MuteChatSheet.kt | 71 +++++++++++ .../screens/profile/GroupProfileMenuItems.kt | 38 +++++- .../screens/profile/GroupProfileScreen.kt | 34 ++++- .../app/messenger/internal/MuteOptionTest.kt | 104 ++++++++++++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 19 +++ .../internal/delegates/MessagingDelegate.kt | 16 +++ 12 files changed, 512 insertions(+), 9 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteOptionTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index eb8c5e81e4..0efc8db555 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 @@ -54,6 +54,19 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object InviteToGroup : ChatStep, Sheet, WrapContentSheet + /** + * How long to mute the conversation for. + * + * A [WrapContentSheet] for the same reason [InviteToGroup] is one: four rows, chosen from the + * group's profile, and the profile is where you end up again once one is picked. Carries + * nothing — the chat is the one the flow is already open on, and the shape the user picks + * travels back as an event rather than as a nav result, because muting is a request the view + * model makes rather than an answer the sheet returns. + */ + @Parcelize + @Serializable + data object MuteChat : ChatStep, Sheet, WrapContentSheet + @Parcelize @Serializable data class Profile(val contact: ChatParticipant): ChatStep diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b6d122b2c6..d5cabfbefb 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -905,6 +905,30 @@ You won’t receive new messages from this chat. You can join it again if it still lets you in Something Went Wrong We were unable to leave the chat. Please try again + + + Mute Notifications + Unmute Notifications + + You\'ll still receive messages and see unread counts. Only notifications are silenced. + 1 Hour + 8 Hours + 1 Week + Always + + Muted until %1$s + Muted + Something Went Wrong + We were unable to mute the chat. Please try again + Something Went Wrong + We were unable to unmute the chat. Please try again Couldn\'t Join We were unable to join the chat. Please try again Your balance doesn\'t meet this chat\'s requirement yet 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 47af5c90da..5fd70993c9 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 @@ -22,6 +22,7 @@ import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen +import com.flipcash.app.messenger.internal.screens.MuteChatSheet import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen @@ -62,7 +63,8 @@ fun ChatFlowScreen( // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, entryProvider = chatEntryProvider(route.identifier, route.openKeyboard), - // ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the + // ChatStep.AmountEntry, ChatStep.InitPayment, ChatStep.InviteToGroup and ChatStep.MuteChat + // are Sheets, so the // flow needs the sheet strategy to draw them as such; without it the step would fall // through to SinglePane and cover the thread. Amount entry // returns its result inside the flow (resultBackNavigator), so the strategy's own @@ -92,6 +94,9 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupInviteSheet() } + annotatedEntry { + FlowMuteChatSheet() + } annotatedEntry { step -> FlowChatProfileScreen(step.contact) } @@ -240,6 +245,25 @@ private fun FlowGroupInviteSheet() { ) } +@Composable +private fun FlowMuteChatSheet() { + val viewModel = flowSharedViewModel() + // Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates + // down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current + + MuteChatSheet( + // Dismissed on the tap rather than on the result: the request is fire-and-forget from here, + // and a failure is reported by the view model's own error bar, which draws over whatever is + // on screen by then. + onMute = { + viewModel.dispatchEvent(ChatViewModel.Event.MuteChat(it)) + dismissSheet() + }, + onDismiss = dismissSheet, + ) +} + @Composable private fun FlowChatProfileScreen(participant: ChatParticipant) { val viewModel = flowSharedViewModel() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index d1a055e96e..4fc672c72d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -5,6 +5,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.ViewerState /** * What the messenger screen is a conversation *with*. @@ -74,6 +75,12 @@ sealed interface ChatSubject { * splitting them would let the screen blur a chat it had just joined for one frame. It is null * for a group hydrated by id, which cannot know it — see `ChatMembership.isMember`. What the * viewer may see treats null as a non-member; what the gate's button offers does not. + * + * [viewerState] is carried whole rather than reduced to a muted flag. A timed mute lapses with + * nothing sent to say so, so what is muted depends on when it is asked — `isMutedAt` is the + * only thing that should answer, and it needs the deadline this keeps. It is the one field + * here with a default, because absent is what a chat holding nothing about its viewer really + * says, and a caller that is not about the viewer has nothing truer to pass. */ data class Group( val chatId: ChatId, @@ -82,6 +89,7 @@ sealed interface ChatSubject { val memberCount: Long, val rules: ChatRules?, val isMember: Boolean?, + val viewerState: ViewerState? = null, ) : ChatSubject { override val title: String get() = groupTitle.orEmpty() override val subtitle: String? get() = null 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 dc043ef159..eb8a6532d7 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 @@ -59,6 +59,7 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.chat.isDmAddressable import com.flipcash.services.user.UserManager @@ -373,6 +374,18 @@ internal class ChatViewModel @Inject constructor( /** The leave went through, so whatever is showing the group's profile should close. */ data object LeftChat : Event + + /** + * A shape picked from the mute sheet. Carries the whole [MuteState] rather than a duration + * because a deadline and forever are not the same kind of thing, and the sheet is the last + * place that can tell them apart. + * + * No confirmation, unlike the leave: muting is undone by the row that replaces it. + */ + data class MuteChat(val mute: MuteState) : Event + + /** The group profile's "Unmute Chat" row. Its own request, not a mute of zero length. */ + data object UnmuteChat : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data object RefreshContact : Event @@ -1472,6 +1485,39 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + // Nothing to dispatch on success: the stored viewer state is what the row reads, + // and `mute` writes it before returning, so the profile flips on the same flow the + // stream would have moved it on. + chatCoordinator.mute(chatId, event.mute) + .onFailure { + trace("failed to mute chat - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToMute), + message = resources.getString(R.string.error_description_failedToMute), + ) + } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + chatCoordinator.unmute(chatId) + .onFailure { + trace("failed to unmute chat - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToUnmute), + message = resources.getString( + R.string.error_description_failedToUnmute, + ), + ) + } + } + .launchIn(viewModelScope) + // A bubble is what the UI has; a citation is what the composer needs, and building one // reads the stored message. A message this device never stored drops the request rather // than opening a strip with nothing in it. @@ -1887,6 +1933,7 @@ internal class ChatViewModel @Inject constructor( memberCount = metadata.rosterSummary.memberCount, rules = metadata.rules, isMember = event.membership.isMember, + viewerState = metadata.viewerState, ), chatType = ChatType.GROUP, resolveState = ResolveState.Resolved, @@ -1909,7 +1956,9 @@ internal class ChatViewModel @Inject constructor( Event.CopyInviteLink, Event.LeaveChat, Event.LeaveConfirmed, - Event.LeftChat -> { state -> state } + Event.LeftChat, + is Event.MuteChat, + Event.UnmuteChat -> { state -> state } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt new file mode 100644 index 0000000000..9b13b5f583 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt @@ -0,0 +1,117 @@ +package com.flipcash.app.messenger.internal + +import android.text.format.DateFormat +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.isMutedAt +import com.getcode.util.formatLocalized +import kotlinx.coroutines.delay +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** + * What the mute picker offers. + * + * The contract admits exactly two mute shapes — until a deadline, or forever — so the picker is a + * few deadlines and the indefinite one, and nothing else. There is no duration that means unmute: + * clearing a mute is its own call, reached from the profile's own row rather than from here. + */ +internal enum class MuteOption( + /** How long from now the mute runs, or `null` for the indefinite option. */ + val duration: Duration?, + val labelRes: Int, +) { + OneHour(1.hours, R.string.action_muteFor_oneHour), + EightHours(8.hours, R.string.action_muteFor_eightHours), + OneWeek(7.days, R.string.action_muteFor_oneWeek), + Forever(null, R.string.action_muteFor_forever); + + /** + * The mute this option asks for, measured from [now]. + * + * The deadline is minted here rather than server-side because the RPC takes one: what the user + * picked is a length, and only the caller knows when they picked it. + */ + fun toMuteState(now: Instant = Clock.System.now()): MuteState = + duration?.let { MuteState.Until(now + it) } ?: MuteState.Forever +} + +/** + * Whether [viewerState] is muted right now, re-answered the moment a timed mute lapses. + * + * A composable rather than a plain read because the lapse has no event behind it. Nothing arrives + * from the server when a deadline passes, and nothing writes the row, so a screen that read the + * state once would keep offering Unmute on a chat that is already audible. This waits out the + * remaining time and then answers false. + */ +@Composable +internal fun rememberIsMuted(viewerState: ViewerState?): Boolean { + val mute = viewerState?.mute + return produceState(initialValue = viewerState.isMutedAt(), mute) { + value = viewerState.isMutedAt() + val until = (mute as? MuteState.Until)?.until ?: return@produceState + val remaining = until - Clock.System.now() + if (remaining.isPositive()) delay(remaining) + value = false + }.value +} + +/** + * How much of a mute deadline has to be said for it to locate the moment. + * + * The same ladder [com.flipcash.app.messenger.internal.screens.components.DateSeparatorRow] climbs, + * pointed forwards: a time alone is unambiguous only within the day it falls in, and a weekday only + * within the week. Pure, and separate from the rendering, because which rung a deadline lands on is + * the part worth pinning — the patterns themselves are the platform's to localize. + */ +internal enum class MuteDeadlineFormat { TimeOnly, WeekdayAndTime, DateAndTime } + +/** Which rung [deadline] lands on, read from [today]. */ +internal fun muteDeadlineFormat(deadline: LocalDate, today: LocalDate): MuteDeadlineFormat { + val dayDiff = deadline.toEpochDays() - today.toEpochDays() + return when { + dayDiff <= 0L -> MuteDeadlineFormat.TimeOnly + dayDiff <= 6L -> MuteDeadlineFormat.WeekdayAndTime + else -> MuteDeadlineFormat.DateAndTime + } +} + +/** + * What the muted row says on its right-hand side, or `null` when the chat is not muted right now. + * + * A timed mute states its deadline because otherwise it reads exactly like a permanent one, and the + * difference is the whole reason the picker offers both. Recomposes through [rememberIsMuted], so + * the readout disappears at the deadline rather than at the next screen open. + */ +@Composable +internal fun rememberMutedLabel(viewerState: ViewerState?): String? { + if (!rememberIsMuted(viewerState)) return null + val until = (viewerState?.mute as? MuteState.Until)?.until + ?: return stringResource(R.string.label_muted) + + val is24Hour = DateFormat.is24HourFormat(LocalContext.current) + val tz = TimeZone.currentSystemDefault() + val time = until.formatLocalized("h:mm a", is24Hour = is24Hour, if24Hour = "H:mm") + val deadline = when ( + muteDeadlineFormat( + deadline = until.toLocalDateTime(tz).date, + today = Clock.System.now().toLocalDateTime(tz).date, + ) + ) { + MuteDeadlineFormat.TimeOnly -> time + MuteDeadlineFormat.WeekdayAndTime -> "${until.formatLocalized("EEEE")} $time" + MuteDeadlineFormat.DateAndTime -> "${until.formatLocalized("MMM d")} $time" + } + return stringResource(R.string.label_mutedUntil, deadline) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt new file mode 100644 index 0000000000..81560c6e9f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt @@ -0,0 +1,71 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.messenger.internal.MuteOption +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.MuteState +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.ListItem +import androidx.compose.material3.Text + +/** + * How long to mute this conversation for. + * + * One row per [MuteOption] and nothing else, because the contract admits exactly two mute shapes — + * a deadline, or forever — and every row here is one of the two. There is no row for unmuting: that + * is a separate request, offered from the profile only while a mute is actually in force. + * + * The deadline is minted on the tap rather than when the sheet opens, so a sheet left sitting still + * means what it says when it is finally used. + */ +@Composable +internal fun MuteChatSheet( + onMute: (MuteState) -> Unit, + onDismiss: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + ) { + AppBarWithTitle( + title = stringResource(R.string.title_muteChat), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + + // Worth saying because muting here does less than the word usually promises: the push still + // arrives and the message still lands in the thread, and only the notification is held back. + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x2), + text = stringResource(R.string.subtitle_muteChat), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + + for (option in MuteOption.entries) { + // No icon: nothing in the icon set says "8 hours", and a stand-in drawable would be + // decoration the row has to explain around. + ListItem( + headline = stringResource(option.labelRes), + icon = null, + showChevron = false, + onClick = { onMute(option.toMuteState()) }, + ) + } + } +} 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 64059feb84..d583f0cac1 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,8 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -14,12 +16,14 @@ import com.flipcash.features.messenger.R * What the group's profile can act on. * * Its own type rather than [com.flipcash.app.messenger.internal.ChatViewModel.Event], because the - * two rows do different kinds of thing: leaving is a request to the conversation's view model, - * inviting is a navigation. Naming both here lets one [com.flipcash.app.menu.MenuList] carry them - * and the screen decide which is which. + * rows do different kinds of thing: leaving and unmuting are requests to the conversation's view + * model, inviting and muting are navigations. Naming them here lets one + * [com.flipcash.app.menu.MenuList] carry them and the screen decide which is which. */ internal sealed interface GroupProfileAction { data object Invite : GroupProfileAction + data object Mute : GroupProfileAction + data object Unmute : GroupProfileAction data object Leave : GroupProfileAction } @@ -52,3 +56,31 @@ internal data object LeaveChat : FullMenuItem() { override val action: GroupProfileAction = GroupProfileAction.Leave } + +/** + * Opens the duration picker. Muting takes a shape — a deadline or forever — so this row cannot act + * on its own the way [UnmuteChat] does; there is nothing for it to ask for yet. + */ +internal data object MuteChat : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.NotificationsOff) + + override val name: String + @Composable get() = stringResource(R.string.title_muteChat) + + override val action: GroupProfileAction = GroupProfileAction.Mute +} + +/** + * Shown in [MuteChat]'s place while the chat is muted *now*. A timed mute puts this row back to + * Mute on its own when its deadline passes — see `rememberIsMuted`, which is what the screen asks. + */ +internal data object UnmuteChat : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Notifications) + + override val name: String + @Composable get() = stringResource(R.string.title_unmuteChat) + + override val action: GroupProfileAction = GroupProfileAction.Unmute +} 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 f4600f025d..23fbe77496 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 @@ -23,6 +23,7 @@ import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.rememberMutedLabel import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R import com.getcode.navigation.flow.rememberFlowNavigator @@ -45,6 +46,11 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group + // Asked per composition rather than stored: a timed mute lapses with nothing sent to say so, + // and this row is where the user would otherwise be looking at a stale Unmute. Non-null is what + // says the chat is muted right now; what it says is the deadline. + val mutedLabel = rememberMutedLabel(group?.viewerState) + // Leaving exits the whole chat flow rather than popping this screen, landing back on the chat // list: a group you have left is not a conversation you are still in, and closing to the // transcript would leave you reading it from behind the gate you just put yourself outside of. @@ -65,12 +71,16 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { modifier = Modifier .fillMaxSize() .padding(innerPadding), - // Both rows are for members only: a non-member has no link to hand out and nothing to - // leave. Membership comes from the roster rather than from the gate, so the leave goes - // away the moment the leave itself lands, not when the balance rule next re-decides. + // Every row is for members only: a non-member has no link to hand out, nothing to + // leave, and no viewer state on a chat they are not in. Membership comes from the + // roster rather than from the gate, so the rows go away the moment the leave itself + // lands, not when the balance rule next re-decides. items = buildList> { if (state.groupInviteUrl != null) add(InviteToGroup) - if (group?.isMember == true) add(LeaveChat) + if (group?.isMember == true) { + add(if (mutedLabel != null) UnmuteChat else MuteChat) + add(LeaveChat) + } }, header = { GroupProfileHeader( @@ -85,10 +95,26 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // The same sheet the transcript's own invite CTA opens, pushed on this flow so // it sits over the profile the user asked from. GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup) + // Muting needs a shape picked before anything can be asked for; unmuting is + // the whole request, so it goes straight to the view model. + GroupProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + GroupProfileAction.Unmute -> + viewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat) GroupProfileAction.Leave -> viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) } }, + // Only the muted row has anything trailing to say. Stating the deadline is what keeps a + // timed mute distinguishable from a permanent one — without it both rows read "Unmute". + endSlot = { item -> + if (item.action is GroupProfileAction.Unmute && mutedLabel != null) { + Text( + text = mutedLabel, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + }, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteOptionTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteOptionTest.kt new file mode 100644 index 0000000000..87e9d99a3d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteOptionTest.kt @@ -0,0 +1,104 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.isMutedAt +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant +import kotlinx.datetime.LocalDate + +/** + * The picker's two jobs: turn a length into the deadline the RPC takes, and leave the indefinite + * option alone. The deadline is the client's to mint, so getting it wrong here mutes for the wrong + * span with nothing on the wire to show for it. + */ +class MuteOptionTest { + + private val now = Instant.fromEpochMilliseconds(1_700_000_000_000) + + @Test + fun `each timed option deadlines its own length from now`() { + assertEquals(MuteState.Until(now + 1.hours), MuteOption.OneHour.toMuteState(now)) + assertEquals(MuteState.Until(now + 8.hours), MuteOption.EightHours.toMuteState(now)) + assertEquals(MuteState.Until(now + 7.days), MuteOption.OneWeek.toMuteState(now)) + } + + @Test + fun `the indefinite option carries no deadline at all`() { + assertEquals(MuteState.Forever, MuteOption.Forever.toMuteState(now)) + } + + @Test + fun `no option means unmute`() { + // The row that clears a mute is a separate request. If any option here read as unmuted the + // moment it was picked, the profile would offer Mute again on a chat it had just muted. + for (option in MuteOption.entries) { + val viewerState = ViewerState(mute = option.toMuteState(now), version = 1L) + assertTrue(viewerState.isMutedAt(now), "${option.name} should be muted at the deadline it was minted from") + } + } + + @Test + fun `a timed option has lapsed once its deadline is behind`() { + val viewerState = ViewerState(mute = MuteOption.OneHour.toMuteState(now), version = 1L) + + assertTrue(viewerState.isMutedAt(now + 59.minutes)) + assertFalse(viewerState.isMutedAt(now + 61.minutes)) + } + + @Test + fun `the indefinite option never lapses`() { + val viewerState = ViewerState(mute = MuteOption.Forever.toMuteState(now), version = 1L) + + assertTrue(viewerState.isMutedAt(now + (365 * 10).days)) + } + + @Test + fun `a deadline today needs only a time`() { + val today = LocalDate(2026, 9, 18) + + assertEquals(MuteDeadlineFormat.TimeOnly, muteDeadlineFormat(today, today)) + } + + @Test + fun `a deadline inside the week names its weekday`() { + val today = LocalDate(2026, 9, 18) + + assertEquals( + MuteDeadlineFormat.WeekdayAndTime, + muteDeadlineFormat(LocalDate(2026, 9, 19), today), + ) + // Six days out is the last one a weekday still locates: the seventh is the same weekday + // again, which would read as a deadline that has already gone. + assertEquals( + MuteDeadlineFormat.WeekdayAndTime, + muteDeadlineFormat(LocalDate(2026, 9, 24), today), + ) + } + + @Test + fun `a deadline a week out or more gets a date`() { + val today = LocalDate(2026, 9, 18) + + assertEquals( + MuteDeadlineFormat.DateAndTime, + muteDeadlineFormat(LocalDate(2026, 9, 25), today), + ) + } + + @Test + fun `a deadline already behind falls back to a time rather than a weekday`() { + // Reachable in the frame between a deadline passing and the row dropping the readout. + // A past date rendered as a weekday would read as a mute still to come. + assertEquals( + MuteDeadlineFormat.TimeOnly, + muteDeadlineFormat(LocalDate(2026, 9, 17), LocalDate(2026, 9, 18)), + ) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index d769191afc..ec3a5587ff 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -14,6 +14,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.TypingState @@ -274,6 +275,24 @@ interface MessagingOperations { /** Notifies the server of the user's typing state in [chatId]. */ suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result + + /** + * Mutes [chatId] for this viewer, [mute] saying until when or indefinitely, and stores the + * viewer state the server answers with. + * + * Not optimistic, unlike the leave: the stored state is versioned, and the version that decides + * whether a later stream update wins is the server's to mint. Writing a guess at it would + * either lose to the echo of this very call or shut a genuinely newer update out. + */ + suspend fun mute(chatId: ChatId, mute: MuteState): Result + + /** + * Clears the mute on [chatId] for this viewer. + * + * Its own call rather than a zero-length [mute]: the contract has two mute shapes, a deadline + * and forever, and neither of them says "not muted". + */ + suspend fun unmute(chatId: ChatId): Result } /** diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 6e3cffc0bd..7aea5900b4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -21,8 +21,10 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.DeleteMessageError import com.flipcash.services.models.EditMessageError import com.flipcash.services.models.UserProfile @@ -395,6 +397,20 @@ class MessagingDelegate @Inject constructor( return messagingController.notifyIsTyping(chatId, typingState) } + override suspend fun mute(chatId: ChatId, mute: MuteState): Result = + chatController.muteChat(chatId, mute).storeViewerState(chatId) + + override suspend fun unmute(chatId: ChatId): Result = + chatController.unmuteChat(chatId).storeViewerState(chatId) + + /** + * Stores the viewer state a mute call answered with, through the same version gate the stream + * writes go through: the call's own echo can arrive on the stream first, and either order has + * to land on the same row. + */ + private suspend fun Result.storeViewerState(chatId: ChatId): Result = + onSuccess { metadataDataSource.updateViewerState(chatId, it) }.map { } + // endregion // region Internal From d75e11ce38b9897b987a8e5a0dad86894513f727 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 19:57:18 -0400 Subject: [PATCH 03/13] feat(chat): silence pushes for a muted chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server sets `muted` on a chat push and sends the push anyway, deliberately, so the client can still store the message. Nothing read the flag, so a muted chat notified exactly as an audible one did. `planPushHandling` now drops `PostNotification` when the payload says the chat was muted at send time, and leaves the sync half untouched: the message is still applied or fetched and the feed still refreshes. Everything user-visible hangs off that one action, so the reply and mark-as-read actions, the conversation styling and the group summary go with it — a suppressed push leaves no summary behind to resurface through. The flag is read off the payload rather than off local viewer state. A timed mute lapses with nothing sent to say so, so the two can disagree; what the server decided when it sent is the answer that matters. Unread counts are unaffected: `unreadCount` is computed from stored metadata in `FeedProjection`, not from anything posted. --- .../app/notifications/PushHandlingPlanner.kt | 18 +++++- .../notifications/PushHandlingPlannerTest.kt | 63 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt index 180a5e6345..fac6fb9ffc 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt @@ -21,10 +21,24 @@ fun planPushHandling( ): List { val sync = syncActionsFor(payload) - // The sync half is the same either way; a title only adds the notification on top. - return if (title == null) sync else sync + PushAction.PostNotification(title, body, payload) + // The sync half is the same either way; a title adds the notification on top, unless the + // chat was silenced when the push was sent. + if (title == null || isMuted(payload)) return sync + return sync + PushAction.PostNotification(title, body, payload) } +/** + * Whether this push arrived for a chat the recipient has silenced. + * + * Read off the payload rather than off the local mute, because the flag is what the server decided + * when it sent: a timed mute lapses with nothing sent to say so, so the two can disagree, and the + * send is the moment that matters. The server delivers a muted push anyway, deliberately, so the + * message still reaches the database — which is why suppression lands here, on the one action that + * is user-visible, and not on the push as a whole. + */ +private fun isMuted(payload: NotificationPayload?): Boolean = + payload?.chatMetadata?.muted == true + /** * The sync work an event class implies, independent of where the push * navigates. diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt index c5387421e9..3973600c4f 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt @@ -35,10 +35,11 @@ class PushHandlingPlannerTest { eventSequence = eventSequence, ) - private fun chatMetadata(message: ChatMessage?) = PushChatMetadata( + private fun chatMetadata(message: ChatMessage?, muted: Boolean = false) = PushChatMetadata( sendingUserId = null, chatType = ChatType.CONTACT_DM, message = message, + muted = muted, ) // region A titled push @@ -199,6 +200,66 @@ class PushHandlingPlannerTest { // endregion + // region A muted chat + + @Test + fun `a muted chat push stores its message and posts nothing`() { + val chatId = ChatId("aa12") + val message = inlinedMessage() + val p = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message, muted = true), + ) + val actions = planPushHandling("Title", "Body", p) + assertEquals(listOf(PushAction.RefreshFeed, PushAction.ApplyMessage(chatId, message)), actions) + } + + @Test + fun `a muted chat push with no inlined message still fetches it`() { + val chatId = ChatId("aa13") + val p = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message = null, muted = true), + ) + val actions = planPushHandling("Title", "Body", p) + assertEquals(listOf(PushAction.RefreshFeed, PushAction.LoadMessages(chatId)), actions) + } + + @Test + fun `muting changes only the notification, not the sync plan`() { + val chatId = ChatId("aa14") + val message = inlinedMessage() + val audible = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message), + ) + val silenced = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message, muted = true), + ) + assertEquals( + planPushHandling("Title", "Body", audible).filterNot { it is PushAction.PostNotification }, + planPushHandling("Title", "Body", silenced), + ) + } + + @Test + fun `the same chat posts once it is no longer muted`() { + val p = payload( + navigation = NavigationTrigger.Chat.ById(ChatId("aa14")), + chatMetadata = chatMetadata(inlinedMessage(), muted = false), + ) + assertTrue(planPushHandling("Title", "Body", p).last() is PushAction.PostNotification) + } + + @Test + fun `a muted push that names no chat is silenced too`() { + val p = payload(chatMetadata = chatMetadata(message = null, muted = true)) + assertEquals(emptyList(), planPushHandling("Title", "Body", p)) + } + + // endregion + companion object { private val TEST_MINT = Mint.usdc } From bcc454bcfc32d6155c529de48e93cb08204da771 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 20:22:40 -0400 Subject: [PATCH 04/13] feat(chat): mute a tip DM from its profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mute was reachable only from a group's profile, though nothing about it is group-shaped: the server holds viewer state on every chat, and a tip DM is the other conversation a user gets pushed about. The row is the same row on both, so it is one generic definition rather than two that can drift; the profiles differ only in what they do with the tap. For the same reason viewer state moves off ChatSubject.Group onto ChatViewModel.State — it describes the chat, not who the chat is with — and the observation feeding it drops the GROUP filter. The DM profile now names its own actions so one MenuList can carry both a block, which is the screen's own business, and a mute, which is the conversation's. Tip DMs only: ChatStep.Profile is also how a group member's profile opens, and a mute row there would silence the whole group from a screen naming one person. Contact DMs have no profile screen. iOS ships the same limit. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 2 +- .../app/messenger/internal/ChatSubject.kt | 8 --- .../app/messenger/internal/ChatViewModel.kt | 30 +++++++++- .../screens/profile/ChatProfileScreen.kt | 57 +++++++++++++++++-- .../screens/profile/ChatProfileViewModel.kt | 6 -- .../screens/profile/GroupProfileMenuItems.kt | 32 +---------- .../screens/profile/GroupProfileScreen.kt | 2 +- .../internal/screens/profile/MuteMenuItems.kt | 47 +++++++++++++++ .../screens/profile/ProfileMenuItems.kt | 26 ++++++++- 9 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt 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 5fd70993c9..9c31647731 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 @@ -273,7 +273,7 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) { viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant)) } - ChatProfileScreen(viewModel) + ChatProfileScreen(viewModel, flowSharedViewModel()) LaunchedEffect(viewModel) { viewModel.eventFlow diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index 4fc672c72d..d1a055e96e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -5,7 +5,6 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.MediaItem -import com.flipcash.services.models.chat.ViewerState /** * What the messenger screen is a conversation *with*. @@ -75,12 +74,6 @@ sealed interface ChatSubject { * splitting them would let the screen blur a chat it had just joined for one frame. It is null * for a group hydrated by id, which cannot know it — see `ChatMembership.isMember`. What the * viewer may see treats null as a non-member; what the gate's button offers does not. - * - * [viewerState] is carried whole rather than reduced to a muted flag. A timed mute lapses with - * nothing sent to say so, so what is muted depends on when it is asked — `isMutedAt` is the - * only thing that should answer, and it needs the deadline this keeps. It is the one field - * here with a default, because absent is what a chat holding nothing about its viewer really - * says, and a caller that is not about the viewer has nothing truer to pass. */ data class Group( val chatId: ChatId, @@ -89,7 +82,6 @@ sealed interface ChatSubject { val memberCount: Long, val rules: ChatRules?, val isMember: Boolean?, - val viewerState: ViewerState? = null, ) : ChatSubject { override val title: String get() = groupTitle.orEmpty() override val subtitle: String? get() = null 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 eb8a6532d7..86a2214fc1 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 @@ -61,6 +61,7 @@ import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.isDmAddressable import com.flipcash.services.user.UserManager import com.flipcash.shared.amountentry.AmountEntryDelegate @@ -168,6 +169,16 @@ internal class ChatViewModel @Inject constructor( val separatorConfig: SeparatorConfig = SeparatorConfig.Continuous(), val chatId: ChatId? = null, val subject: ChatSubject? = null, + /** + * What this chat holds about the viewer, or null while nothing is known. + * + * On the chat rather than on the [ChatSubject] because it is not about who the + * conversation is with: a DM is muted exactly the way a group is, and both profiles offer + * the row. Carried whole rather than reduced to a muted flag — a timed mute lapses with + * nothing sent to say so, so what is muted depends on when it is asked, and `isMutedAt` + * needs the deadline this keeps. + */ + val viewerState: ViewerState? = null, // The kind of DM this conversation is, resolved from the fast local contact lookup ahead of // the participant's server profile (which resolves over the network for tip DMs). Starts // UNKNOWN and settles to CONTACT_DM / TIP_DM as soon as the chat opens; the send button and @@ -375,6 +386,9 @@ internal class ChatViewModel @Inject constructor( /** The leave went through, so whatever is showing the group's profile should close. */ data object LeftChat : Event + /** This chat's viewer state moved, from the stream or from the viewer's own request. */ + data class OnViewerStateResolved(val viewerState: ViewerState?) : Event + /** * A shape picked from the mute sheet. Carries the whole [MuteState] rather than a duration * because a deadline and forever are not the same kind of thing, and the sheet is the last @@ -1111,6 +1125,18 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnGroupResolved(it)) } .launchIn(viewModelScope) + // The same metadata, minus the type filter: every chat has viewer state and every chat can + // be muted, so this one is not the group chrome's business. Observed rather than read with + // the chat because a mute made on another device arrives on the stream, and because leaving + // clears it server-side. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeMetadata(it) } + .map { it?.metadata?.viewerState } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnViewerStateResolved(it)) } + .launchIn(viewModelScope) + // The cache starts empty and fills in, so this is observed rather than read once — a // requirement resolved against an empty cache would render its amount with no token beside // it and never correct itself. @@ -1933,7 +1959,6 @@ internal class ChatViewModel @Inject constructor( memberCount = metadata.rosterSummary.memberCount, rules = metadata.rules, isMember = event.membership.isMember, - viewerState = metadata.viewerState, ), chatType = ChatType.GROUP, resolveState = ResolveState.Resolved, @@ -1953,6 +1978,9 @@ internal class ChatViewModel @Inject constructor( ) ) } + is Event.OnViewerStateResolved -> { state -> + state.copy(viewerState = event.viewerState) + } Event.CopyInviteLink, Event.LeaveChat, Event.LeaveConfirmed, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 1ee7127da7..a232c42be3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -21,10 +21,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.asSubject +import com.flipcash.app.messenger.internal.rememberMutedLabel import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatType import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle @@ -35,10 +39,32 @@ import com.getcode.view.LoadingSuccessState import kotlin.time.Instant +/** + * A DM counterparty's profile. + * + * Two view models rather than one, because the screen says two kinds of thing about two different + * subjects. [viewModel] holds the person — their profile, their join date, whether they are + * blocked — and is the screen's own. [chatViewModel] is the conversation's, shared with the + * transcript, and it is what muting goes through: a mute is held by the chat, not by the person, + * and the same mute is reachable from a group's profile where there is no person at all. + * + * The mute row is shown for a tip DM only. This route is also how a group member's profile opens, + * and there the chat behind it is the group — a mute row on a member's profile would silence the + * whole group from a screen that names one person. Contact DMs never reach this screen + * ([com.flipcash.app.messenger.internal.ChatSubject.Contact] answers `canViewProfile` false). + */ @Composable -internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) { +internal fun ChatProfileScreen( + viewModel: ChatProfileViewModel, + chatViewModel: ChatViewModel, +) { val flowNavigator = rememberFlowNavigator() val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + + // Asked per composition rather than stored: a timed mute lapses with nothing sent to say so, + // and this row is where the user would otherwise be looking at a stale Unmute. + val mutedLabel = rememberMutedLabel(chatState.viewerState) CodeScaffold( topBar = { @@ -49,7 +75,12 @@ internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) { modifier = Modifier .fillMaxSize() .padding(innerPadding), - items = state.menuItems, + items = buildList> { + add(BlockUser) + if (chatState.chatType == ChatType.TIP_DM) { + add(if (mutedLabel != null) UnmuteDm else MuteDm) + } + }, header = { ProfileHeader( participant = state.participant, @@ -59,9 +90,19 @@ internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) { .padding(top = CodeTheme.dimens.grid.x7), ) }, - onItemClick = { viewModel.dispatchEvent(it.action) }, + onItemClick = { item -> + when (item.action) { + ChatProfileAction.Block -> + viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) + // Muting needs a shape picked before anything can be asked for; unmuting is + // the whole request, so it goes straight to the conversation's view model. + ChatProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + ChatProfileAction.Unmute -> + chatViewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat) + } + }, endSlot = { item -> - val loading = item.action == ChatProfileViewModel.Event.BlockUser && + val loading = item.action == ChatProfileAction.Block && state.processingState.state == LoadingSuccessState.State.Loading if (loading) { CodeCircularProgressIndicator( @@ -69,6 +110,14 @@ internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) { color = CodeTheme.colors.textSecondary, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), ) + } else if (item.action == ChatProfileAction.Unmute && mutedLabel != null) { + // Stating the deadline is what keeps a timed mute distinguishable from a + // permanent one — without it both rows read "Unmute". + Text( + text = mutedLabel, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) } else { Icon( painter = painterResource(R.drawable.ic_chevron_right), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 67bcc60626..285cdb1b53 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -5,7 +5,6 @@ import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.menu.MenuItem import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ProfileController @@ -27,10 +26,6 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant -private val ProfileMenuItems = buildList { - add(BlockUser) -} - @HiltViewModel internal class ChatProfileViewModel @Inject constructor( private val contactCoordinator: ContactCoordinator, @@ -48,7 +43,6 @@ internal class ChatProfileViewModel @Inject constructor( data class State( val participant: ChatParticipant? = null, val joinDate: Instant? = null, - val menuItems: List> = ProfileMenuItems, val processingState: LoadingSuccessState = LoadingSuccessState(), ) 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 d583f0cac1..59747129b2 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,8 +2,6 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -57,30 +55,6 @@ internal data object LeaveChat : FullMenuItem() { override val action: GroupProfileAction = GroupProfileAction.Leave } -/** - * Opens the duration picker. Muting takes a shape — a deadline or forever — so this row cannot act - * on its own the way [UnmuteChat] does; there is nothing for it to ask for yet. - */ -internal data object MuteChat : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.NotificationsOff) - - override val name: String - @Composable get() = stringResource(R.string.title_muteChat) - - override val action: GroupProfileAction = GroupProfileAction.Mute -} - -/** - * Shown in [MuteChat]'s place while the chat is muted *now*. A timed mute puts this row back to - * Mute on its own when its deadline passes — see `rememberIsMuted`, which is what the screen asks. - */ -internal data object UnmuteChat : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Notifications) - - override val name: String - @Composable get() = stringResource(R.string.title_unmuteChat) - - override val action: GroupProfileAction = GroupProfileAction.Unmute -} +/** The group's mute rows. Shared definitions; see [MuteChatItem]. */ +internal val MuteChat = MuteChatItem(GroupProfileAction.Mute) +internal val UnmuteChat = UnmuteChatItem(GroupProfileAction.Unmute) 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 23fbe77496..61eeeda4dd 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 @@ -49,7 +49,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // Asked per composition rather than stored: a timed mute lapses with nothing sent to say so, // and this row is where the user would otherwise be looking at a stale Unmute. Non-null is what // says the chat is muted right now; what it says is the deadline. - val mutedLabel = rememberMutedLabel(group?.viewerState) + val mutedLabel = rememberMutedLabel(state.viewerState) // Leaving exits the whole chat flow rather than popping this screen, landing back on the chat // list: a group you have left is not a conversation you are still in, and closing to the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt new file mode 100644 index 0000000000..5ce5757c1d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt @@ -0,0 +1,47 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.features.messenger.R + +/** + * The row that opens the duration picker. + * + * Generic over the action because the two profiles that offer it — a group's and a DM's — name + * their own actions, and a [com.flipcash.app.menu.MenuItem] carries one action type. Muting is the + * same thing on both, so it is one row definition rather than two that can drift apart; what + * differs is only what the screen does with the tap. + * + * Cannot act on its own the way [UnmuteChatItem] does: muting takes a shape — a deadline or + * forever — and there is nothing for this row to ask for until one is picked. + * + * Declare instances as top-level `val`s, naming the action type explicitly. [FullMenuItem] mints an + * id per instance, so one built inside a composition would get a new one on every recomposition; + * and inference off a `data object` argument picks that object's own type rather than the sealed + * interface the menu is typed on. + */ +internal class MuteChatItem(override val action: T) : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.NotificationsOff) + + override val name: String + @Composable get() = stringResource(R.string.title_muteChat) +} + +/** + * Shown in [MuteChatItem]'s place while the chat is muted *now*. A timed mute puts the mute row + * back on its own when its deadline passes — see `rememberIsMuted`, which is what the screen asks. + */ +internal class UnmuteChatItem(override val action: T) : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Notifications) + + override val name: String + @Composable get() = stringResource(R.string.title_unmuteChat) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index d0a021b3a3..a7a4aaae8c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -9,11 +9,31 @@ import androidx.compose.ui.res.stringResource import com.flipcash.app.menu.FullMenuItem import com.flipcash.features.messenger.R -internal data object BlockUser: FullMenuItem() { +/** + * What a DM counterparty's profile can act on. + * + * Its own type rather than [ChatProfileViewModel.Event] for the same reason [GroupProfileAction] + * is one: the rows do different kinds of thing and answer to different view models. Blocking is + * this screen's own business, while muting is the conversation's — the chat is what gets muted, + * not the person — so naming the actions here lets one [com.flipcash.app.menu.MenuList] carry both + * and the screen route each tap to whichever holds it. + */ +internal sealed interface ChatProfileAction { + data object Block : ChatProfileAction + data object Mute : ChatProfileAction + data object Unmute : ChatProfileAction +} + +internal data object BlockUser : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Outlined.Block) override val name: String @Composable get() = stringResource(R.string.title_block) - override val action: ChatProfileViewModel.Event = ChatProfileViewModel.Event.BlockUser -} \ No newline at end of file + + override val action: ChatProfileAction = ChatProfileAction.Block +} + +/** The DM's mute rows. Shared definitions; see [MuteChatItem]. */ +internal val MuteDm = MuteChatItem(ChatProfileAction.Mute) +internal val UnmuteDm = UnmuteChatItem(ChatProfileAction.Unmute) From ef8859e9b098d87bdffec0b94a2bc03de1aede47 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 11:46:20 -0400 Subject: [PATCH 05/13] feat(chat): show a bell wherever a muted chat is named Muting had no trace outside the profile that set it. A muted chat looked like any other in the list and in its own transcript, so the only way to tell why it had gone quiet was to open the profile and read the row. MutedIndicator draws a bell at caption size in the secondary text colour, on the chats list row and in the transcript's title bar. It takes the viewer state rather than a muted boolean: a timed mute lapses with nothing sent to say so, so a caller that resolved the answer when it built the row would hand over a value that goes stale in place. rememberIsMuted moves to chat-ui from messenger so both surfaces share the one implementation that waits the deadline out. An audible chat emits no layout node at all, so neither Row spends its spacing on it and the list row keeps its full preview width. The bell carries its own contentDescription and needs no help from its container: a Compose merging node concatenates a child's description into its own rather than dropping it, so the row reads its name, its timestamp and then "Muted". MutedIndicatorTest asserts that through a clickable Row rather than taking it on trust. --- .../app/messenger/internal/MuteOption.kt | 24 +--- .../internal/screens/components/ChatTopBar.kt | 32 +++-- .../tipping/internal/components/TipChatRow.kt | 31 ++++- .../shared/chat/ui/ChatSummaryMapping.kt | 4 +- .../shared/chat/ui/ConversationReference.kt | 9 ++ .../flipcash/shared/chat/ui/MutedIndicator.kt | 66 ++++++++++ .../shared/chat/ui/MutedIndicatorTest.kt | 119 ++++++++++++++++++ 7 files changed, 247 insertions(+), 38 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MutedIndicatorTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt index 9b13b5f583..513d9e021a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt @@ -2,15 +2,13 @@ package com.flipcash.app.messenger.internal import android.text.format.DateFormat import androidx.compose.runtime.Composable -import androidx.compose.runtime.produceState import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.ViewerState -import com.flipcash.services.models.chat.isMutedAt +import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.util.formatLocalized -import kotlinx.coroutines.delay import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -47,26 +45,6 @@ internal enum class MuteOption( duration?.let { MuteState.Until(now + it) } ?: MuteState.Forever } -/** - * Whether [viewerState] is muted right now, re-answered the moment a timed mute lapses. - * - * A composable rather than a plain read because the lapse has no event behind it. Nothing arrives - * from the server when a deadline passes, and nothing writes the row, so a screen that read the - * state once would keep offering Unmute on a chat that is already audible. This waits out the - * remaining time and then answers false. - */ -@Composable -internal fun rememberIsMuted(viewerState: ViewerState?): Boolean { - val mute = viewerState?.mute - return produceState(initialValue = viewerState.isMutedAt(), mute) { - value = viewerState.isMutedAt() - val until = (mute as? MuteState.Until)?.until ?: return@produceState - val remaining = until - Clock.System.now() - if (remaining.isPositive()) delay(remaining) - value = false - }.value -} - /** * How much of a mute deadline has to be said for it to locate the moment. * diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 23688fd983..07c061449a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -50,6 +50,7 @@ import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.ui.MutedIndicator import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge @@ -167,14 +168,29 @@ private fun ConversationTitleBar( ) Column(modifier = Modifier.weight(1f)) { - Text( - // Name-or-handle for a DM, the group's title for a group. A DM bar stays - // one line (node 9443:9094): the handle is the only identity a name-less - // tip counterparty has, so it is the title there rather than a second row. - text = state.subject?.title.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + // Name-or-handle for a DM, the group's title for a group. A DM bar + // stays one line (node 9443:9094): the handle is the only identity a + // name-less tip counterparty has, so it is the title there rather than + // a second row. + // + // Gives up width to the indicator rather than taking the whole line, + // so a long name loses its tail instead of pushing the bell off the bar. + modifier = Modifier.weight(1f, fill = false), + text = state.subject?.title.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + + // Beside the name, not at the bar's edge: what is muted is this chat, and + // the name is what says which chat. An audible one emits nothing, so the + // spacing above is not spent either. + MutedIndicator(viewerState = state.viewerState) + } // A group always shows its size, whether or not the viewer is in it — the // count is what a join changes, and node 10125:19153 -> 10125:19201 is the // same bar at two values of it. A DM has nothing to count. diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 74ecfd4314..03bd7afd10 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -1,15 +1,20 @@ package com.flipcash.app.tipping.internal.components +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.MutedIndicator import com.flipcash.shared.chat.ui.SubtitleText import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme @@ -51,13 +56,27 @@ internal fun TipChatRow( ) }, subtitle = { - ChatRowSubtitle( - isTyping = chat.isTyping, - preview = chat.lastMessagePreview, - fallback = { - SubtitleText("") + // The mute sits at the end of this line rather than the one above it, where the + // timestamp and unread badge already compete for the trailing corner. It reads as the + // bottom of a column of chat-level state, and matches where iOS draws it. + Row( + verticalAlignment = Alignment.CenterVertically, + // An audible chat emits no indicator at all, so the gap is not spent either and + // the preview keeps the full width. + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Box(modifier = Modifier.weight(1f)) { + ChatRowSubtitle( + isTyping = chat.isTyping, + preview = chat.lastMessagePreview, + fallback = { + SubtitleText("") + } + ) } - ) + + MutedIndicator(viewerState = chat.viewerState) + } }, showDivider = showDivider, onClick = onClick, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 1707d26c4d..963abcf995 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -13,7 +13,8 @@ import com.getcode.util.resources.ResourceHelper /** * Maps a [ChatSummary] to the presentation [ConversationReference] shared by every * conversation-style list (send contacts, tip DMs, …): counterparty identity, a - * formatted last-message preview, the last-activity timestamp, and unread count. + * formatted last-message preview, the last-activity timestamp, unread count, and the viewer's + * own state on the chat. * * The counterparty ([ConversationReference.displayName]/[ConversationReference.image]) * is taken from the chat member that isn't [selfId] — used directly by rows with no @@ -43,6 +44,7 @@ fun ChatSummary.toConversationReference( lastMessagePreview = formatPreview(selfId, tokensByMint, resources), lastActivity = metadata.lastActivity, unreadCount = unreadCount, + viewerState = metadata.viewerState, ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index d7a916e11c..99304e1c92 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -4,6 +4,7 @@ import com.getcode.opencode.model.core.ID import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.nameOrHandle import kotlin.time.Instant @@ -34,6 +35,14 @@ data class ConversationReference( val lastActivity: Instant? = null, val unreadCount: Int = 0, val isTyping: Boolean = false, + /** + * The viewer's own state on this chat, which is where the row's mute comes from. + * + * The state itself rather than a muted flag: a timed mute lapses with nothing sent to say so, + * so a boolean resolved when the row was built would go stale in place. [MutedIndicator] holds + * the deadline and stops drawing at it. + */ + val viewerState: ViewerState? = null, ) { /** * What to call this row: the chat's [title] for a group, otherwise the counterparty's diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt new file mode 100644 index 0000000000..1297da0950 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt @@ -0,0 +1,66 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.core.R +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.isMutedAt +import com.getcode.theme.CodeTheme +import kotlinx.coroutines.delay +import kotlin.time.Clock + +/** + * The bell drawn wherever a chat is named, while that chat is muted right now. + * + * Takes the viewer state rather than a muted boolean on purpose. A timed mute lapses with nothing + * sent to say so, so a caller that resolved the answer itself would hand this a value that is + * already wrong by the time the deadline passes; [rememberIsMuted] waits the deadline out and stops + * drawing on its own. Emits nothing at all when the chat is audible, so a row pays no layout for it. + * + * Sized and tinted to sit with the caption text it stands beside — the row's timestamp, the title + * bar's name — rather than to be noticed on its own. It states a setting, it does not ask for + * anything. + */ +@Composable +fun MutedIndicator( + viewerState: ViewerState?, + modifier: Modifier = Modifier, +) { + if (!rememberIsMuted(viewerState)) return + Icon( + modifier = modifier.size(CodeTheme.dimens.staticGrid.x4), + imageVector = Icons.Outlined.NotificationsOff, + // Reaches a screen reader through the merged label of whatever container holds it — + // Compose concatenates a child's description into its parent's rather than dropping it, + // so the row reads its name, its timestamp and then this. + contentDescription = stringResource(R.string.label_muted), + tint = CodeTheme.colors.textSecondary, + ) +} + +/** + * Whether [viewerState] is muted right now, re-answered the moment a timed mute lapses. + * + * A composable rather than a plain read because the lapse has no event behind it. Nothing arrives + * from the server when a deadline passes, and nothing writes the row, so a screen that read the + * state once would keep showing a chat as muted after it had gone audible. This waits out the + * remaining time and then answers false. + */ +@Composable +fun rememberIsMuted(viewerState: ViewerState?): Boolean { + val mute = viewerState?.mute + return produceState(initialValue = viewerState.isMutedAt(), mute) { + value = viewerState.isMutedAt() + val until = (mute as? MuteState.Until)?.until ?: return@produceState + val remaining = until - Clock.System.now() + if (remaining.isPositive()) delay(remaining) + value = false + }.value +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MutedIndicatorTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MutedIndicatorTest.kt new file mode 100644 index 0000000000..d653bf6728 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MutedIndicatorTest.kt @@ -0,0 +1,119 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours + +/** + * The indicator's two questions: whether it draws, and whether a screen reader hears it. + * + * The second is the reason this is a Compose test rather than a unit test of [rememberIsMuted]. + * Both surfaces that draw the bell sit inside a merging semantics node — a clickable row, a + * tappable title — and what such a node does with a child's description is the platform's choice, + * not ours. Compose concatenates rather than discarding, which is why the bell carries its own + * description instead of the containers appending to theirs; if that ever stopped being true the + * indicator would go silent with nothing else failing. + */ +@RunWith(RobolectricTestRunner::class) +class MutedIndicatorTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val mutedLabel = "Muted" + + private fun setIndicator(viewerState: ViewerState?) { + composeTestRule.setContent { + DesignSystem { + MutedIndicator(viewerState = viewerState) + } + } + } + + // --------------------------------------------------------------- + // Whether it draws + // --------------------------------------------------------------- + + @Test + fun `drawn while muted forever`() { + setIndicator(ViewerState(mute = MuteState.Forever)) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertIsDisplayed() + } + + @Test + fun `drawn while a timed mute is still running`() { + setIndicator(ViewerState(mute = MuteState.Until(Clock.System.now() + 1.hours))) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertIsDisplayed() + } + + @Test + fun `not drawn once a timed mute has lapsed`() { + // The state the server last sent, read after its deadline. Nothing arrives to clear it, so + // this is what a chat that has gone audible on its own actually looks like in the store. + setIndicator(ViewerState(mute = MuteState.Until(Clock.System.now() - 1.hours))) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } + + @Test + fun `not drawn when the chat is not muted`() { + setIndicator(ViewerState(mute = null)) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } + + @Test + fun `not drawn when there is no viewer state`() { + setIndicator(null) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } + + // --------------------------------------------------------------- + // Whether a screen reader hears it + // --------------------------------------------------------------- + + @Test + fun `description survives a merging container`() { + composeTestRule.setContent { + DesignSystem { + Row(modifier = Modifier.clickable {}) { + Text("Alice") + MutedIndicator(viewerState = ViewerState(mute = MuteState.Forever)) + } + } + } + + // The clickable is the merged node; finding the description on it is what says the bell + // reached the label the row is read out as, rather than only existing under it. + composeTestRule + .onNode(hasClickAction() and hasContentDescription(mutedLabel)) + .assertExists() + } + + @Test + fun `merging container says nothing about muting when the chat is audible`() { + composeTestRule.setContent { + DesignSystem { + Row(modifier = Modifier.clickable {}) { + Text("Alice") + MutedIndicator(viewerState = null) + } + } + } + + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } +} From 4127229c203702656fac74c46da256187a1825d2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 18:05:43 -0400 Subject: [PATCH 06/13] feat(chat): put the mute picker in charge of unmuting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile row toggled between "Mute Notifications" and "Unmute Notifications", so the words under the finger became the opposite of what had just been tapped — which reads as a missed tap — and the deadline it drew alongside left the label nothing to work with. The row now reads the same either way and always opens the picker. The picker gains a "Never" row while there is a mute to clear, and what the mute currently is moves to an amber chip under the profile title, where it sits apart from the chat's own facts and can say "Muted until Sunday 2:04 AM" in full. The chip holds its line height while absent so the rows below don't jump when a timed mute lapses. The picker's rows become cards with icons — a clock for the ones that name a length, a bell-slash for "Always", a bell for "Never" — matching the invite sheet next door and iOS' sheet of the same name (code-ios-app#806). ChoiceRow gains a Painter overload so a Material vector can be passed where there is no drawable id. "Never" is pinned on the way out: viewer state converges before the sheet has finished animating away, so read live the list would lose its first row while the user is still watching it. --- .../core/src/main/res/values/strings.xml | 18 +-- .../flipcash/app/messenger/ChatFlowScreen.kt | 10 ++ .../messenger/internal/ChatMuteStatusChip.kt | 116 ++++++++++++++++++ .../internal/screens/MuteChatSheet.kt | 83 ++++++++++--- .../screens/profile/ChatProfileScreen.kt | 31 ++--- .../screens/profile/GroupProfileMenuItems.kt | 8 +- .../screens/profile/GroupProfileScreen.kt | 35 ++---- .../internal/screens/profile/MuteMenuItems.kt | 21 +--- .../screens/profile/ProfileMenuItems.kt | 4 +- .../com/getcode/ui/components/ChoiceRow.kt | 22 +++- 10 files changed, 260 insertions(+), 88 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index d5cabfbefb..3c68a09443 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -907,13 +907,12 @@ We were unable to leave the chat. Please try again + offers durations plus an indefinite option; unmuting is its own request, which is why + "Never" is a row of its own rather than a fifth duration. Wording and the duration set + match iOS, which took them from WhatsApp\'s sheet plus Telegram\'s one-hour option. The + options name the state they produce ("1 Week") rather than the act ("Mute for 1 week"), + which is also what lets "Never" sit among them. --> Mute Notifications - Unmute Notifications You\'ll still receive messages and see unread counts. Only notifications are silenced. @@ -921,8 +920,11 @@ 8 Hours 1 Week Always - + + Never + Muted until %1$s Muted Something Went Wrong 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 9c31647731..046c1158cc 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 @@ -28,6 +28,7 @@ import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen +import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost @@ -248,11 +249,16 @@ private fun FlowGroupInviteSheet() { @Composable private fun FlowMuteChatSheet() { val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() // Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates // down rather than having its scene deleted mid-frame. val dismissSheet = LocalBottomSheetDismissDispatcher.current MuteChatSheet( + // Asked per composition rather than passed in as a boolean the caller computed: a timed + // mute lapses with nothing sent to say so, and the sheet's unmute row is what would + // otherwise be left standing over a chat that is already audible again. + isMuted = rememberIsMuted(state.viewerState), // Dismissed on the tap rather than on the result: the request is fire-and-forget from here, // and a failure is reported by the view model's own error bar, which draws over whatever is // on screen by then. @@ -260,6 +266,10 @@ private fun FlowMuteChatSheet() { viewModel.dispatchEvent(ChatViewModel.Event.MuteChat(it)) dismissSheet() }, + onUnmute = { + viewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat) + dismissSheet() + }, onDismiss = dismissSheet, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt new file mode 100644 index 0000000000..e99ae070d7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt @@ -0,0 +1,116 @@ +package com.flipcash.app.messenger.internal + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ViewerState +import com.getcode.theme.CodeTheme + +/** + * What the chat's mute currently is, drawn under the title on both chat settings surfaces — + * "Muted until 5:56 PM" for a timed mute, plain "Muted" for an indefinite one, and nothing at all + * while the chat is audible. + * + * It sits here rather than on the mute row because the row has no space for it, and because a row + * whose own label reported the state read as a missed tap: the words under the finger changed to + * the opposite of what was just tapped. The row now says the same thing either way and this says + * what happened. + * + * Stating the deadline is what makes a timed mute trustworthy — without it a timed mute cannot be + * told apart from an indefinite one, which is the whole reason the picker offers both. + * + * Drawn as a chip rather than as another line of secondary text: the lines above it are the chat's + * own identity — its name, its size, when someone joined — and this is the viewer's setting. Given + * their styling it read as a fourth fact about the chat. Amber, because a mute is the one thing on + * the screen that stops being true on its own, and the colour is what separates it from the settled + * facts above; tinted rather than solid so it stays under the title. The ground is the amber itself + * at a tenth strength rather than a theme token, because what it has to sit on is whatever the host + * screen's background is. + * + * Takes the [ViewerState] rather than a label so the countdown behind it is this composable's — + * see [rememberMutedLabel], which drops the text the moment a timed mute lapses with nothing sent + * from the server to say so. + */ +@Composable +internal fun ChatMuteStatusChip( + viewerState: ViewerState?, + modifier: Modifier = Modifier, +) { + val label = rememberMutedLabel(viewerState) + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + // Holds the line's height whether or not there is a mute, so the rows below don't move the + // moment one lapses or is cleared — that reads as the screen re-laying itself out rather + // than one fact going away. Never drawn, never read aloud. + Chip( + label = stringResource(R.string.label_muted), + modifier = Modifier + .alpha(0f) + .clearAndSetSemantics { }, + ) + + // Springs in and fades out. Arriving is the event worth seeing — the user just chose it — + // so it gets the overshoot; going away is either their unmute or a deadline passing, and + // neither wants attention drawn to it. + AnimatedVisibility( + visible = label != null, + enter = fadeIn() + scaleIn(initialScale = 0.85f, animationSpec = spring()), + exit = fadeOut(animationSpec = tween(durationMillis = 200)), + ) { + Chip(label = label.orEmpty()) + } + } +} + +@Composable +private fun Chip( + label: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .background( + color = CodeTheme.colors.warning.copy(alpha = 0.1f), + shape = CircleShape, + ) + .padding( + horizontal = CodeTheme.dimens.staticGrid.x2, + vertical = CodeTheme.dimens.staticGrid.x1, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + ) { + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), + imageVector = Icons.Outlined.NotificationsOff, + contentDescription = null, + tint = CodeTheme.colors.warning, + ) + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.warning, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt index 81560c6e9f..ffadef829a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt @@ -1,12 +1,23 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.flipcash.app.messenger.internal.MuteOption @@ -15,24 +26,39 @@ import com.flipcash.services.models.chat.MuteState import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.ListItem -import androidx.compose.material3.Text +import com.getcode.ui.components.ChoiceRow /** * How long to mute this conversation for. * - * One row per [MuteOption] and nothing else, because the contract admits exactly two mute shapes — - * a deadline, or forever — and every row here is one of the two. There is no row for unmuting: that - * is a separate request, offered from the profile only while a mute is actually in force. + * One row per [MuteOption], drawn as the chats flow's other choice screen draws its rows — a card + * per choice rather than a settings list, matching [GroupInviteSheet] and iOS' sheet of the same + * name. + * + * The contract admits exactly two mute shapes, a deadline or forever, so every duration row is one + * of the two. "Never" is the unmute: still its own request rather than a mute of zero length, and + * it reads as a duration only because every label here names the state it produces rather than the + * act that gets there. * * The deadline is minted on the tap rather than when the sheet opens, so a sheet left sitting still * means what it says when it is finally used. + * + * @param isMuted whether the chat is muted *right now*, which is what decides the unmute row. Read + * live by the caller, so a mute that lapses under an open sheet drops the row with it. */ @Composable internal fun MuteChatSheet( + isMuted: Boolean, onMute: (MuteState) -> Unit, + onUnmute: () -> Unit, onDismiss: () -> Unit, ) { + // Pinned on the way out, because the store converges before the sheet has finished animating + // away: read live, the list would gain or lose its first row while the user is still watching + // it. Null while the sheet is idle, so a lapsing mute still drops the row. + var pinnedOffersUnmute: Boolean? by remember { mutableStateOf(null) } + val offersUnmute = pinnedOffersUnmute ?: isMuted + Column( modifier = Modifier .fillMaxWidth() @@ -49,23 +75,48 @@ internal fun MuteChatSheet( Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x2), + .padding(horizontal = CodeTheme.dimens.inset), text = stringResource(R.string.subtitle_muteChat), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, textAlign = TextAlign.Center, ) - for (option in MuteOption.entries) { - // No icon: nothing in the icon set says "8 hours", and a stand-in drawable would be - // decoration the row has to explain around. - ListItem( - headline = stringResource(option.labelRes), - icon = null, - showChevron = false, - onClick = { onMute(option.toMuteState()) }, - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + // Unmuting first: it is the one row that undoes what the others did, and it is only + // here at all while there is something to undo. + if (offersUnmute) { + ChoiceRow( + label = stringResource(R.string.action_muteFor_never), + icon = rememberVectorPainter(Icons.Outlined.Notifications), + onClick = { + pinnedOffersUnmute = offersUnmute + onUnmute() + }, + ) + } + for (option in MuteOption.entries) { + ChoiceRow( + label = stringResource(option.labelRes), + // A clock for the rows that name a length, and the bell-slash for the one that + // doesn't: nothing in the icon set says "8 hours", and what the timed rows have + // in common is that they end. + icon = rememberVectorPainter( + if (option.duration != null) Icons.Outlined.Schedule + else Icons.Outlined.NotificationsOff + ), + onClick = { + pinnedOffersUnmute = offersUnmute + onMute(option.toMuteState()) + }, + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index a232c42be3..441bb29664 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -23,12 +23,13 @@ import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.ChatMuteStatusChip import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.asSubject -import com.flipcash.app.messenger.internal.rememberMutedLabel import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle @@ -62,10 +63,6 @@ internal fun ChatProfileScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() - // Asked per composition rather than stored: a timed mute lapses with nothing sent to say so, - // and this row is where the user would otherwise be looking at a stale Unmute. - val mutedLabel = rememberMutedLabel(chatState.viewerState) - CodeScaffold( topBar = { AppBarWithTitle(onBackIconClicked = { flowNavigator.back() }) @@ -78,13 +75,14 @@ internal fun ChatProfileScreen( items = buildList> { add(BlockUser) if (chatState.chatType == ChatType.TIP_DM) { - add(if (mutedLabel != null) UnmuteDm else MuteDm) + add(MuteDm) } }, header = { ProfileHeader( participant = state.participant, joinDate = state.joinDate, + viewerState = chatState.viewerState, modifier = Modifier .fillMaxWidth() .padding(top = CodeTheme.dimens.grid.x7), @@ -94,11 +92,9 @@ internal fun ChatProfileScreen( when (item.action) { ChatProfileAction.Block -> viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) - // Muting needs a shape picked before anything can be asked for; unmuting is - // the whole request, so it goes straight to the conversation's view model. + // Both muting and unmuting go through the picker, which is why this row + // navigates either way rather than acting on one of them here. ChatProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) - ChatProfileAction.Unmute -> - chatViewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat) } }, endSlot = { item -> @@ -110,14 +106,6 @@ internal fun ChatProfileScreen( color = CodeTheme.colors.textSecondary, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), ) - } else if (item.action == ChatProfileAction.Unmute && mutedLabel != null) { - // Stating the deadline is what keeps a timed mute distinguishable from a - // permanent one — without it both rows read "Unmute". - Text( - text = mutedLabel, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) } else { Icon( painter = painterResource(R.drawable.ic_chevron_right), @@ -136,6 +124,7 @@ internal fun ProfileHeader( participant: ChatParticipant?, joinDate: Instant?, modifier: Modifier = Modifier, + viewerState: ViewerState? = null, ) { Column( modifier = modifier, @@ -179,5 +168,11 @@ internal fun ProfileHeader( color = CodeTheme.colors.textSecondary, ) } + // Last, because it is the only line here that is the viewer's setting rather than a fact + // about the person, and the only one that can stop being true while the screen is open. + ChatMuteStatusChip( + viewerState = viewerState, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + ) } } 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 59747129b2..c0c0e2eb53 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 @@ -14,14 +14,13 @@ import com.flipcash.features.messenger.R * What the group's profile can act on. * * Its own type rather than [com.flipcash.app.messenger.internal.ChatViewModel.Event], because the - * rows do different kinds of thing: leaving and unmuting are requests to the conversation's view - * model, inviting and muting are navigations. Naming them here lets one + * rows do different kinds of thing: leaving is a request to the conversation's view model, inviting + * and muting are navigations. Naming them here lets one * [com.flipcash.app.menu.MenuList] carry them and the screen decide which is which. */ internal sealed interface GroupProfileAction { data object Invite : GroupProfileAction data object Mute : GroupProfileAction - data object Unmute : GroupProfileAction data object Leave : GroupProfileAction } @@ -55,6 +54,5 @@ internal data object LeaveChat : FullMenuItem() { override val action: GroupProfileAction = GroupProfileAction.Leave } -/** The group's mute rows. Shared definitions; see [MuteChatItem]. */ +/** The group's mute row. Shared definition; see [MuteChatItem]. */ internal val MuteChat = MuteChatItem(GroupProfileAction.Mute) -internal val UnmuteChat = UnmuteChatItem(GroupProfileAction.Unmute) 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 61eeeda4dd..b432562c56 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 @@ -21,11 +21,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.ChatMuteStatusChip import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.app.messenger.internal.rememberMutedLabel import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle @@ -46,11 +47,6 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group - // Asked per composition rather than stored: a timed mute lapses with nothing sent to say so, - // and this row is where the user would otherwise be looking at a stale Unmute. Non-null is what - // says the chat is muted right now; what it says is the deadline. - val mutedLabel = rememberMutedLabel(state.viewerState) - // Leaving exits the whole chat flow rather than popping this screen, landing back on the chat // list: a group you have left is not a conversation you are still in, and closing to the // transcript would leave you reading it from behind the gate you just put yourself outside of. @@ -78,13 +74,14 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { items = buildList> { if (state.groupInviteUrl != null) add(InviteToGroup) if (group?.isMember == true) { - add(if (mutedLabel != null) UnmuteChat else MuteChat) + add(MuteChat) add(LeaveChat) } }, header = { GroupProfileHeader( group = group, + viewerState = state.viewerState, modifier = Modifier .fillMaxWidth() .padding(top = CodeTheme.dimens.grid.x7), @@ -95,26 +92,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // The same sheet the transcript's own invite CTA opens, pushed on this flow so // it sits over the profile the user asked from. GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup) - // Muting needs a shape picked before anything can be asked for; unmuting is - // the whole request, so it goes straight to the view model. + // Both muting and unmuting go through the picker, which is why this row + // navigates either way rather than acting on one of them here. GroupProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) - GroupProfileAction.Unmute -> - viewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat) GroupProfileAction.Leave -> viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) } }, - // Only the muted row has anything trailing to say. Stating the deadline is what keeps a - // timed mute distinguishable from a permanent one — without it both rows read "Unmute". - endSlot = { item -> - if (item.action is GroupProfileAction.Unmute && mutedLabel != null) { - Text( - text = mutedLabel, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - }, ) } } @@ -124,6 +108,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { internal fun GroupProfileHeader( group: ChatSubject.Group?, modifier: Modifier = Modifier, + viewerState: ViewerState? = null, ) { Column( modifier = modifier, @@ -157,5 +142,11 @@ internal fun GroupProfileHeader( color = CodeTheme.colors.textSecondary, ) } + // Last, because it is the only line here that is the viewer's setting rather than a fact + // about the group, and the only one that can stop being true while the screen is open. + ChatMuteStatusChip( + viewerState = viewerState, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt index 5ce5757c1d..c2a453a624 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt @@ -1,7 +1,6 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -18,8 +17,12 @@ import com.flipcash.features.messenger.R * same thing on both, so it is one row definition rather than two that can drift apart; what * differs is only what the screen does with the tap. * - * Cannot act on its own the way [UnmuteChatItem] does: muting takes a shape — a deadline or - * forever — and there is nothing for this row to ask for until one is picked. + * One row, reading the same either way, whether or not the chat is currently muted: the picker it + * opens is where unmuting lives too. A row that became "Unmute Notifications" under the tap that + * muted the chat read as a missed tap — the words under the finger had turned into the opposite of + * what was just chosen — and it left the row with nowhere to say *until when*. What the mute + * currently is belongs to [com.flipcash.app.messenger.internal.ChatMuteStatusChip], under the + * profile's title. * * Declare instances as top-level `val`s, naming the action type explicitly. [FullMenuItem] mints an * id per instance, so one built inside a composition would get a new one on every recomposition; @@ -33,15 +36,3 @@ internal class MuteChatItem(override val action: T) : FullMenuItem() { override val name: String @Composable get() = stringResource(R.string.title_muteChat) } - -/** - * Shown in [MuteChatItem]'s place while the chat is muted *now*. A timed mute puts the mute row - * back on its own when its deadline passes — see `rememberIsMuted`, which is what the screen asks. - */ -internal class UnmuteChatItem(override val action: T) : FullMenuItem() { - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Notifications) - - override val name: String - @Composable get() = stringResource(R.string.title_unmuteChat) -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index a7a4aaae8c..569cac053d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -21,7 +21,6 @@ import com.flipcash.features.messenger.R internal sealed interface ChatProfileAction { data object Block : ChatProfileAction data object Mute : ChatProfileAction - data object Unmute : ChatProfileAction } internal data object BlockUser : FullMenuItem() { @@ -34,6 +33,5 @@ internal data object BlockUser : FullMenuItem() { override val action: ChatProfileAction = ChatProfileAction.Block } -/** The DM's mute rows. Shared definitions; see [MuteChatItem]. */ +/** The DM's mute row. Shared definition; see [MuteChatItem]. */ internal val MuteDm = MuteChatItem(ChatProfileAction.Mute) -internal val UnmuteDm = UnmuteChatItem(ChatProfileAction.Unmute) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt index c24fa9a81c..f17616aac7 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -35,6 +36,25 @@ fun ChoiceRow( icon: Int, modifier: Modifier = Modifier, onClick: () -> Unit, +) { + ChoiceRow( + label = label, + icon = painterResource(icon), + modifier = modifier, + onClick = onClick, + ) +} + +/** + * The same row for a caller whose glyph is not a drawable — a Material vector, say, which has no + * resource id to pass. + */ +@Composable +fun ChoiceRow( + label: String, + icon: Painter, + modifier: Modifier = Modifier, + onClick: () -> Unit, ) { Row( modifier = modifier @@ -48,7 +68,7 @@ fun ChoiceRow( ) { Icon( modifier = Modifier.size(IconSize), - painter = painterResource(icon), + painter = icon, contentDescription = null, tint = CodeTheme.colors.textMain, ) From 7c60f89a27d5d1f5d7870d1ddd89f7ea5f2e919a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 18:14:25 -0400 Subject: [PATCH 07/13] fix(chat): put mute above block on a DM's profile Block sat first, so the routine reversible setting was under the one that ends the conversation. Mute now leads and block closes the list, which is also the order the group's profile already puts leaving in. --- .../messenger/internal/screens/profile/ChatProfileScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 441bb29664..ee5debfc35 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -72,11 +72,13 @@ internal fun ChatProfileScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding), + // Mute first, block last: the one that is reversible and routine sits above the one + // that ends the conversation, the same order the group's profile puts leaving in. items = buildList> { - add(BlockUser) if (chatState.chatType == ChatType.TIP_DM) { add(MuteDm) } + add(BlockUser) }, header = { ProfileHeader( From 9e3780f891734586a24b08c91c25f1a3f5a9f217 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 18:14:26 -0400 Subject: [PATCH 08/13] fix(chat): draw the muted bell at caption scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 20.dp the bell read larger than iOS' in both places it appears — over the 14sp row subtitle it trails and the 16sp title bar name it follows. 15.dp sits with that text instead of above it. Matching iOS by box size alone would not have closed the gap: a Material glyph fills its box, where the SF Symbol on the other side carries its own padding. --- .../kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt index 1297da0950..d7495b30a0 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt @@ -24,7 +24,7 @@ import kotlin.time.Clock * already wrong by the time the deadline passes; [rememberIsMuted] waits the deadline out and stops * drawing on its own. Emits nothing at all when the chat is audible, so a row pays no layout for it. * - * Sized and tinted to sit with the caption text it stands beside — the row's timestamp, the title + * Sized and tinted to sit with the caption text it stands beside — the row's preview, the title * bar's name — rather than to be noticed on its own. It states a setting, it does not ask for * anything. */ @@ -35,7 +35,10 @@ fun MutedIndicator( ) { if (!rememberIsMuted(viewerState)) return Icon( - modifier = modifier.size(CodeTheme.dimens.staticGrid.x4), + // Caption scale, a touch under the 14sp subtitle it trails and well under the 16sp title + // bar name. A Material glyph fills its box where the SF Symbol iOS draws carries its own + // padding, so matching iOS by the box size alone lands a visibly heavier bell. + modifier = modifier.size(CodeTheme.dimens.staticGrid.x3), imageVector = Icons.Outlined.NotificationsOff, // Reaches a screen reader through the merged label of whatever container holds it — // Compose concatenates a child's description into its parent's rather than dropping it, From 103b34d07ee5e651f052268713b7c0c7ce5034ec Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 18:25:41 -0400 Subject: [PATCH 09/13] fix(chat): open the gap under a profile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both profile screens ran their header straight into the first menu row: MenuList emits its header slot and its items back to back with nothing between them, and neither header supplied a bottom padding, so the mute chip sat against Mute Notifications and the screen read as one block. Pad the header 40dp at the bottom, which is what iOS spends here — its 16pt stack spacing plus the row block's 24pt top inset. The row's own 25dp inset already matches iOS's. --- .../internal/screens/profile/ChatProfileScreen.kt | 10 +++++++++- .../internal/screens/profile/GroupProfileScreen.kt | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index ee5debfc35..cdd2119998 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -87,7 +87,15 @@ internal fun ChatProfileScreen( viewerState = chatState.viewerState, modifier = Modifier .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x7), + // The gap under the header is the header's own, because MenuList puts + // nothing between its header slot and the first row. 40dp matches what + // iOS spends here (its 16pt stack spacing plus the row block's 24pt top + // inset); without it the mute chip sits against the first row and the + // screen reads as one block rather than a title above a list. + .padding( + top = CodeTheme.dimens.grid.x7, + bottom = CodeTheme.dimens.grid.x8, + ), ) }, onItemClick = { item -> 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 b432562c56..643676dd91 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 @@ -84,7 +84,15 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { viewerState = state.viewerState, modifier = Modifier .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x7), + // The gap under the header is the header's own, because MenuList puts + // nothing between its header slot and the first row. 40dp matches what + // iOS spends here (its 16pt stack spacing plus the row block's 24pt top + // inset); without it the mute chip sits against the first row and the + // screen reads as one block rather than a title above a list. + .padding( + top = CodeTheme.dimens.grid.x7, + bottom = CodeTheme.dimens.grid.x8, + ), ) }, onItemClick = { item -> From c653e17fbed1e27afe989cb25ac40a6be36f353f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 18:47:16 -0400 Subject: [PATCH 10/13] fix(chat): widen the avatar-to-name gap on a profile 10dp put the name as close to the picture as the handle and join date are to each other, so the four read as one undifferentiated stack. 15dp separates the picture from the identity block that follows it, and is the nearest step on the 5-grid to iOS' 16pt. --- .../messenger/internal/screens/profile/ChatProfileScreen.kt | 4 +++- .../messenger/internal/screens/profile/GroupProfileScreen.kt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index cdd2119998..fc93d31e67 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -147,7 +147,9 @@ internal fun ProfileHeader( .clip(CircleShape), ) Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + // Wider than the 5dp that binds the identity lines below it, so the name reads as the + // start of that block rather than as another line of the picture. + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = participant?.name.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, 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 643676dd91..d42bd5ebd3 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 @@ -129,7 +129,9 @@ internal fun GroupProfileHeader( .clip(CircleShape), ) Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + // Wider than the 5dp that binds the identity lines below it, so the name reads as the + // start of that block rather than as another line of the picture. + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = group?.title.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, From 34634aec410f4a20d113ee12b31a71756e594ee4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 18:47:17 -0400 Subject: [PATCH 11/13] fix(chat): drop the trailing period from the mute sheet subtitle Matches iOS, and the 103 of 109 `subtitle_` strings that already end without one. --- 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 3c68a09443..b52e9f77ed 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -915,7 +915,7 @@ Mute Notifications - You\'ll still receive messages and see unread counts. Only notifications are silenced. + You\'ll still receive messages and see unread counts. Only notifications are silenced 1 Hour 8 Hours 1 Week From 608eefcac2bd9e4280339e250e3ee533f4513acd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 19:13:39 -0400 Subject: [PATCH 12/13] fix(chat): fill in the muted bell The indicator in the chats list and the transcript title bar drew Material's outlined NotificationsOff, where iOS' MuteBell draws bell.slash.fill. Switched to the filled glyph so the two agree. The mute row and the profile chip keep the outline, matching iOS' ChatMuteRow and ChatMuteStatusLabel. --- .../kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt index d7495b30a0..7841929c7e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt @@ -2,7 +2,7 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState @@ -26,7 +26,9 @@ import kotlin.time.Clock * * Sized and tinted to sit with the caption text it stands beside — the row's preview, the title * bar's name — rather than to be noticed on its own. It states a setting, it does not ask for - * anything. + * anything. Filled rather than outlined, matching iOS' `bell.slash.fill`. The mute row and the + * profile chip stay outlined, as iOS' do — those sit at body size beside a label that already + * names the setting, where this one has to carry it alone at caption size. */ @Composable fun MutedIndicator( @@ -39,7 +41,7 @@ fun MutedIndicator( // bar name. A Material glyph fills its box where the SF Symbol iOS draws carries its own // padding, so matching iOS by the box size alone lands a visibly heavier bell. modifier = modifier.size(CodeTheme.dimens.staticGrid.x3), - imageVector = Icons.Outlined.NotificationsOff, + imageVector = Icons.Filled.NotificationsOff, // Reaches a screen reader through the merged label of whatever container holds it — // Compose concatenates a child's description into its parent's rather than dropping it, // so the row reads its name, its timestamp and then this. From 072146f7d97eaa8b1fbb2790b99c0306133fdb78 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 19:30:14 -0400 Subject: [PATCH 13/13] test(chat): cover the mute flag's trip off the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planPushHandling is handed an already-decoded NotificationPayload, so its suppression tests say nothing about how muted gets there. A mapping that dropped push.v1.ChatMetadata.muted would leave every one of them green and every muted chat audible. Covers asPayload: set, clear, absent, and a payload with no chat metadata. The absent case pins the behaviour the rollout depends on — the field is a bare bool, so a server that does not set it yet reads as not muted and leaves notifications alone. --- .../extensions/PushPayloadMuteMappingTest.kt | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PushPayloadMuteMappingTest.kt diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PushPayloadMuteMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PushPayloadMuteMappingTest.kt new file mode 100644 index 0000000000..106633f45d --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PushPayloadMuteMappingTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.push.v1.Model as PushModels +import com.google.protobuf.ByteString +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `push.v1.ChatMetadata.muted` is what silences a notification, and it is the only input to that + * decision — the planner is handed the decoded model, so a mapping that dropped this field would + * leave every planner test passing and every muted chat still audible. This covers the one step + * between the wire and that decision. + */ +class PushPayloadMuteMappingTest { + + private fun payload( + withChatMetadata: Boolean = true, + muted: Boolean? = null, + ): PushModels.Payload { + val builder = PushModels.Payload.newBuilder() + .setNavigation( + PushModels.Navigation.newBuilder() + .setChatId( + Common.ChatId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(32) { 1 })) + ) + ) + if (withChatMetadata) builder.setChatMetadata(chatMetadata(muted)) + return builder.build() + } + + private fun chatMetadata(muted: Boolean?): PushModels.ChatMetadata = + PushModels.ChatMetadata.newBuilder() + .setType(ChatModel.ChatType.CONTACT_DM) + // Left unset when null, so the default rather than an explicit false is what is read. + .apply { if (muted != null) setMuted(muted) } + .build() + + @Test + fun `a muted push decodes as muted`() { + val decoded = payload(muted = true).asPayload() + assertTrue(decoded.chatMetadata!!.muted) + } + + @Test + fun `an audible push decodes as not muted`() { + val decoded = payload(muted = false).asPayload() + assertFalse(decoded.chatMetadata!!.muted) + } + + /** + * The field is a bare `bool`, so a sender that has not been taught to set it yet is + * indistinguishable from one saying "not muted" — and that is the behaviour we want: an + * unaware server must leave notifications audible, never silence them. + */ + @Test + fun `an unset muted field decodes as not muted`() { + val decoded = payload(muted = null).asPayload() + assertFalse(decoded.chatMetadata!!.muted) + } + + @Test + fun `a push with no chat metadata has none to read`() { + assertNull(payload(withChatMetadata = false).asPayload().chatMetadata) + } + + /** Muting must not disturb anything else the payload carries. */ + @Test + fun `muting changes only the muted flag`() { + val audible = payload(muted = false).asPayload() + val silenced = payload(muted = true).asPayload() + assertEquals(audible.navigation, silenced.navigation) + assertEquals(audible.category, silenced.category) + val audibleChat = audible.chatMetadata!! + val silencedChat = silenced.chatMetadata!! + assertEquals(audibleChat.chatType, silencedChat.chatType) + assertEquals(audibleChat.message, silencedChat.message) + } +}