From 4968123c3a494ea6254b0fd683289ba42946803a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:01:28 -0400 Subject: [PATCH 01/18] perf(chat): index chat_messages for the paged transcript read The transcript reads newest-first within a chat, an order the composite primary key does not serve, so every page was a fresh scan and sort of the table. Walking 2,000 rows back in a 40,000-row table measured 536 ms unindexed against 20 ms indexed. Also makes that order total. timestamp_epoch_ms alone leaves rows sharing a millisecond to SQLite, and a paged read that re-queries per page can duplicate or skip one across the seam. countNewerThan measures how far back a message sits, which bounds the walk to a quoted message. --- .../33.json | 787 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../app/persistence/dao/ChatMessageDao.kt | 16 +- .../persistence/entities/ChatMessageEntity.kt | 13 + .../app/persistence/dao/ChatMessageDaoTest.kt | 46 + .../sources/ChatMessageDataSource.kt | 8 + 6 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/33.json diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/33.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/33.json new file mode 100644 index 000000000..db1708730 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/33.json @@ -0,0 +1,787 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "2e3e1849ac3f28ae6737cdcca9247ae6", + "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, 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" + } + ], + "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, '2e3e1849ac3f28ae6737cdcca9247ae6')" + ] + } +} \ 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 336a30435..0098fac43 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 @@ -93,8 +93,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 29, to = 30), // chat_metadata.analytics_counted_through AutoMigration(from = 30, to = 31), // user_profiles.username (nullable) AutoMigration(from = 31, to = 32, spec = FlipcashDatabase.Migration31To32::class), + AutoMigration(from = 32, to = 33), // chat_messages index on (chat_id_hex, timestamp_epoch_ms) ], - version = 32, + version = 33, ) @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/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 0a36d103b..a134687a6 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -13,10 +13,15 @@ import kotlinx.coroutines.flow.Flow @Dao interface ChatMessageDao { - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms ASC") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms ASC, message_id ASC") fun observeMessages(chatIdHex: String): Flow> - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC") + /** + * `message_id` breaks ties because `timestamp_epoch_ms` alone is not a total order: messages + * sent in a burst, or stamped from one server clock read, share a millisecond. A paged read + * re-queries per page, so an unstable order there duplicates or skips a row across the seam. + */ + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC, message_id DESC") fun observeMessagesPaged(chatIdHex: String): PagingSource /** @@ -61,6 +66,13 @@ interface ChatMessageDao { @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId LIMIT 1") suspend fun getMessage(chatIdHex: String, messageId: Long): ChatMessageEntity? + /** + * How many messages in [chatIdHex] are newer than [timestampEpochMs] — the distance back from + * the newest message, which is what bounds the walk to a quoted message. + */ + @Query("SELECT COUNT(*) FROM chat_messages WHERE chat_id_hex = :chatIdHex AND timestamp_epoch_ms > :timestampEpochMs") + suspend fun countNewerThan(chatIdHex: String, timestampEpochMs: Long): Int + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ChatMessageEntity) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index 370697d99..df9c27e0a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -2,6 +2,7 @@ package com.flipcash.app.persistence.entities import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import com.flipcash.app.persistence.converters.MessageContentSerialized enum class MessageStatus { @@ -10,9 +11,21 @@ enum class MessageStatus { FAILED, } +/** + * The transcript reads this table one page at a time, ordered newest-first within a chat, and the + * composite primary key `(chat_id_hex, message_id)` does not serve that order. Without the index + * every page is a fresh scan and sort of the whole table: walking 2,000 rows back in a 40,000-row + * table measured 536 ms unindexed against 20 ms indexed. + */ @Entity( tableName = "chat_messages", primaryKeys = ["chat_id_hex", "message_id"], + indices = [ + Index( + value = ["chat_id_hex", "timestamp_epoch_ms"], + name = "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + ), + ], ) data class ChatMessageEntity( @ColumnInfo(name = "chat_id_hex") val chatIdHex: String, diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 601b4ca95..1ce9cccfe 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.persistence.dao import android.content.Context +import androidx.paging.PagingSource import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.flipcash.app.persistence.FlipcashDatabase @@ -137,6 +138,51 @@ class ChatMessageDaoTest { assertEquals(3L, stored.unreadSeq) } + /** + * Two messages can share a millisecond — a burst send, or a server batch stamped from one clock + * read. `timestamp_epoch_ms DESC` alone leaves their order to SQLite, so a paged read that + * re-queries per page can hand back the same row twice or skip one. The tie-breaker makes the + * order total. + */ + @Test + fun `messages sharing a timestamp order by message id, newest first`() = runTest { + val sameMs = 5_000L + dao.upsert( + listOf( + text(1, "first").copy(timestampEpochMs = sameMs), + text(2, "second").copy(timestampEpochMs = sameMs), + text(3, "third").copy(timestampEpochMs = sameMs), + ) + ) + + val page = dao.observeMessagesPaged(CHAT_HEX).load( + PagingSource.LoadParams.Refresh(null, 10, false) + ) as PagingSource.LoadResult.Page + + assertEquals(listOf(3L, 2L, 1L), page.data.map { it.messageId }) + } + + /** + * How far back a message sits from the newest, which is what bounds the jump walk. Counts + * strictly newer rows, so the newest message is at distance 0. + */ + @Test + fun `countNewerThan measures the distance back to a message`() = runTest { + dao.upsert((1L..5L).map { text(it, "m$it") }) + + assertEquals(0, dao.countNewerThan(CHAT_HEX, 5 * 1_000)) + assertEquals(2, dao.countNewerThan(CHAT_HEX, 3 * 1_000)) + assertEquals(4, dao.countNewerThan(CHAT_HEX, 1 * 1_000)) + } + + @Test + fun `countNewerThan ignores other chats`() = runTest { + dao.upsert((1L..5L).map { text(it, "m$it") }) + dao.upsert(text(9, "elsewhere").copy(chatIdHex = OTHER_HEX)) + + assertEquals(2, dao.countNewerThan(CHAT_HEX, 3 * 1_000)) + } + private companion object { const val CHAT_HEX = "aabb" const val OTHER_HEX = "ccdd" diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index f36569854..4c54d20ad 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -132,6 +132,14 @@ class ChatMessageDataSource @Inject constructor( suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? = db?.chatMessageDao()?.getMessage(mapper.chatIdHex(chatId), messageId)?.let { toChatMessage(it) } + /** How far back [messageId] sits from the newest message in [chatId], or `null` if unknown. */ + suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? { + val dao = db?.chatMessageDao() ?: return null + val hex = mapper.chatIdHex(chatId) + val stored = dao.getMessage(hex, messageId) ?: return null + return dao.countNewerThan(hex, stored.timestampEpochMs) + } + suspend fun getInboundMessagesInRange( chatId: ChatId, selfId: ID, From 2af89c218f3113d36cccfeff56be90b649a02e93 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:03:35 -0400 Subject: [PATCH 02/18] feat(chat): carry a reply target through sendMessage sendMessage takes an optional replyToMessageId and wraps the body in MessageContent.Reply, which the proto mappers already handle in both directions. The optimistic row carries the same payload as the request, so a quote renders before the server answers. The parameter defaults to null: the notification quick-reply replies to a conversation rather than to a message, and is unchanged. --- .../flipcash/shared/chat/ChatCoordinator.kt | 14 ++++- .../internal/delegates/MessagingDelegate.kt | 17 +++-- .../shared/chat/MessagingMutationTest.kt | 63 +++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) 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 0088d4bf0..57280883b 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 @@ -146,8 +146,18 @@ interface MessagingOperations { /** Fetches the full message history for [chatId] from the server and persists locally. */ suspend fun loadMessages(chatId: ChatId) - /** Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. */ - suspend fun sendMessage(chatId: ChatId, content: String): Result + /** + * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. + * + * [replyToMessageId] cites a message in the same chat, which wraps the body in + * [MessageContent.Reply]. It defaults to `null` so a caller with no message to cite — the + * notification quick-reply replies to a conversation, not to a message — is unchanged. + */ + suspend fun sendMessage( + chatId: ChatId, + content: String, + replyToMessageId: Long? = null, + ): Result /** Retries a failed pending message: resets to SENDING and re-sends to the server. */ suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): 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 d82b1d194..95591b82e 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 @@ -187,7 +187,11 @@ class MessagingDelegate @Inject constructor( } } - override suspend fun sendMessage(chatId: ChatId, content: String): Result { + override suspend fun sendMessage( + chatId: ChatId, + content: String, + replyToMessageId: Long?, + ): Result { if (content.isBlank()) { return Result.failure(IllegalArgumentException("Cannot send a blank message")) } @@ -195,14 +199,19 @@ class MessagingDelegate @Inject constructor( val senderId = userManager.accountId ?: return Result.failure(IllegalStateException("Cannot send message without an account")) - val content = listOf(MessageContent.Text(content)) + val body = listOf(MessageContent.Text(content)) + // The optimistic row and the request carry the same payload, so the quote renders before + // the server answers rather than appearing when it does. + val payload = replyToMessageId + ?.let { listOf(MessageContent.Reply(repliedMessageId = it, content = body)) } + ?: body val (_, clientMessageId) = messageDataSource.insertPending( chatId = chatId, - content = content, + content = payload, senderId = senderId, ) - return messagingController.sendMessage(chatId, content, clientMessageId) + return messagingController.sendMessage(chatId, payload, clientMessageId) .onSuccess { serverMessage -> messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) advanceReadPointer(chatId, serverMessage.messageId) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt index 3d645e813..0c3a6a775 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt @@ -1,10 +1,12 @@ package com.flipcash.shared.chat import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.PendingMessage import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.EditMessageError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.delegates.MessagingDelegate @@ -12,6 +14,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -170,4 +173,64 @@ class MessagingMutationTest { coVerify(exactly = 0) { controller.editMessage(any(), any(), any(), any()) } assertTrue(delegate.observePendingMutations(chatId).first().isEmpty()) } + + /** + * A sender that cites a message wraps the body rather than sending alongside it: the proto + * carries one content list, and the reply *is* the message. + */ + @Test + fun `a reply wraps the text in Reply content carrying the target id`() = runTest { + // Relaxed: sendMessage's success path advances the read pointer through this same + // controller, which is not what these tests are asserting on. + val controller = mockk(relaxed = true) + val dataSource = mockk(relaxed = true) + val sentContent = slot>() + coEvery { dataSource.insertPending(any(), any(), any()) } returns + PendingMessage(message = stored, clientMessageId = ClientMessageId(byteArrayOf(9))) + coEvery { controller.sendMessage(any(), capture(sentContent), any()) } returns + Result.success(stored) + + delegateWith(controller, dataSource).sendMessage(chatId, "sure", replyToMessageId = 7) + + val reply = assertIs(sentContent.captured.single()) + assertEquals(7L, reply.repliedMessageId) + assertEquals(listOf(MessageContent.Text("sure")), reply.content) + } + + @Test + fun `an ordinary message is still bare text`() = runTest { + // Relaxed: sendMessage's success path advances the read pointer through this same + // controller, which is not what these tests are asserting on. + val controller = mockk(relaxed = true) + val dataSource = mockk(relaxed = true) + val sentContent = slot>() + coEvery { dataSource.insertPending(any(), any(), any()) } returns + PendingMessage(message = stored, clientMessageId = ClientMessageId(byteArrayOf(9))) + coEvery { controller.sendMessage(any(), capture(sentContent), any()) } returns + Result.success(stored) + + delegateWith(controller, dataSource).sendMessage(chatId, "hello") + + assertEquals(listOf(MessageContent.Text("hello")), sentContent.captured) + } + + /** + * The optimistic row carries the same payload as the request, so the quote is on screen before + * the server answers rather than appearing when it does. + */ + @Test + fun `the optimistic row carries the reply too`() = runTest { + // Relaxed: sendMessage's success path advances the read pointer through this same + // controller, which is not what these tests are asserting on. + val controller = mockk(relaxed = true) + val dataSource = mockk(relaxed = true) + val pendingContent = slot>() + coEvery { dataSource.insertPending(any(), capture(pendingContent), any()) } returns + PendingMessage(message = stored, clientMessageId = ClientMessageId(byteArrayOf(9))) + coEvery { controller.sendMessage(any(), any(), any()) } returns Result.success(stored) + + delegateWith(controller, dataSource).sendMessage(chatId, "sure", replyToMessageId = 7) + + assertIs(pendingContent.captured.single()) + } } From ed8f19f2a451cf5d37d2385386058e68de31d795 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:04:03 -0400 Subject: [PATCH 03/18] feat(chat): add the ChatQuote model shared by both reply surfaces --- .../flipcash/shared/chat/models/ChatQuote.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt new file mode 100644 index 000000000..c9a9b1cda --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt @@ -0,0 +1,40 @@ +package com.flipcash.shared.chat.models + +import androidx.compose.ui.graphics.Color +import com.getcode.opencode.model.financial.Fiat + +/** + * A citation of another message, rendered identically by the composer strip and by the panel inside + * a sent bubble. + * + * Resolved once, where the transcript is mapped, rather than by each surface: the original has to be + * looked up in the local database either way, and two lookups would let the strip and the bubble + * disagree about the same message. + */ +data class ChatQuote( + /** The cited message, which is what a tap on the panel scrolls to. */ + val messageId: Long, + val authorName: String, + val snippet: ChatQuoteSnippet, + /** + * The cited sender's colour, or `null` when the message carries no sender id. + * + * Nullable because the palette is: `generateComplementaryColorPalette` returns `null` for a + * message with no id, and the fallback for that case is a theme colour only a composable can + * read. Resolving the rest here keeps the SHA-512 derivation to once per quote instead of once + * per frame. + */ + val accent: Color?, +) + +/** What a quote shows of the message it cites. */ +sealed interface ChatQuoteSnippet { + data class Text(val body: String) : ChatQuoteSnippet + + /** + * A quoted payment shows its currency flag, amount and token name rather than a bare number, + * matching what iOS shipped: the amount alone does not identify which payment is being + * discussed. + */ + data class Cash(val amount: Fiat, val tokenName: String) : ChatQuoteSnippet +} From 59f16d5622092d96befd0bd647b4616b730f77e9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:05:23 -0400 Subject: [PATCH 04/18] feat(chat): read through a reply wrapper on the transcript bubble plainText unwraps Reply so copy and edit act on the body rather than failing on the citation around it, matching how PendingMutation.replacingText already applies an edit. isSelectable widens to any capability. Excluding Reply left a cash bubble unselectable, since Reply is the only thing a cash message allows. --- .../shared/chat/models/ChatListItem.kt | 22 +++++++++++--- .../chat/models/ChatListItemSelectionTest.kt | 30 ++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index a5dc8cdb4..8a4eadba7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -36,19 +36,33 @@ sealed interface ChatListItem { * offer, and a later role taxonomy changes the resolver rather than the menu. */ val capabilities: Set = emptySet(), + /** + * The message this one cites, resolved in the transcript pipeline, or `null` when it cites + * nothing — or when it cites a message this device has never stored. The absent case + * renders the body without a panel rather than an error, so a reply to history that was + * never synced still reads as a message. + */ + val quote: ChatQuote? = null, ) : ChatListItem { /** The body a Copy or an Edit acts on, or `null` for a bubble that carries no text. */ val plainText: String? - get() = (content as? MessageContent.Text)?.text + get() = when (val content = content) { + is MessageContent.Text -> content.text + // Copy and edit act on what the user wrote, not on the citation around it. + // PendingMutation.replacingText unwraps the same way when the edit is applied. + is MessageContent.Reply -> + content.content.filterIsInstance().firstOrNull()?.text + else -> null + } /** * Whether long-pressing this bubble should open the selection bar. * - * [MessageCapability.Reply] alone is not enough — replies have no surface yet, so a cash - * bubble would open a bar with nothing in it. + * Any capability is enough. Reply used to be excluded because it had no surface, which left + * a cash bubble — whose only capability is Reply — unselectable. */ val isSelectable: Boolean - get() = capabilities.any { it != MessageCapability.Reply } + get() = capabilities.isNotEmpty() override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt index 5a7cfbb62..08638df76 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemSelectionTest.kt @@ -42,11 +42,33 @@ class ChatListItemSelectionTest { } @Test - fun `reply alone does not make a bubble selectable`() { - // Cash resolves to Reply only, and replies have no surface yet, so a long-press here would - // open a bar with nothing in it. + fun `a bubble offering only reply is selectable`() { + // Cash resolves to Reply only. Excluding Reply here was right while replies had no + // surface; now it is the one action a cash bubble offers, so excluding it would leave that + // bubble unselectable. val cash = bubble(MessageContent.Text("hello"), setOf(MessageCapability.Reply)) - assertFalse(cash.isSelectable) + assertTrue(cash.isSelectable) + } + + @Test + fun `plainText reads through a reply to the body`() { + // Copy and edit act on what the user wrote, not on the wrapper that cites another message. + val reply = bubble( + MessageContent.Reply(repliedMessageId = 4, content = listOf(MessageContent.Text("sure"))), + setOf(MessageCapability.Copy), + ) + + assertEquals("sure", reply.plainText) + } + + @Test + fun `plainText is null for a reply with no text in it`() { + val reply = bubble( + MessageContent.Reply(repliedMessageId = 4, content = emptyList()), + setOf(MessageCapability.Copy), + ) + + assertNull(reply.plainText) } @Test From 4abb84f6fcbdaaaa332a1cb644cd51565584b98c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:09:25 -0400 Subject: [PATCH 05/18] test(chat): pin the sender palette to the values iOS pins The quote panel colours a citation by its sender, and iOS's ComplementaryPalette.swift ports this derivation arithmetic-for-arithmetic. Pinning the same hexes turns a divergence into a failure rather than two apps quietly colouring the same person differently. Compared as 8-bit hex, which is what iOS pins: the two platforms carry HSV out to different float types, so equal colours are equal once quantized to a channel byte and not before. --- .../chat/ui/ComplementaryPaletteParityTest.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComplementaryPaletteParityTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComplementaryPaletteParityTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComplementaryPaletteParityTest.kt new file mode 100644 index 000000000..6aa837341 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComplementaryPaletteParityTest.kt @@ -0,0 +1,47 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.getcode.ui.utils.generateComplementaryColorPalette +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * The quote panel colours a citation by its sender, and iOS's ComplementaryPalette.swift is a + * documented arithmetic-for-arithmetic port of this function. These are the hexes iOS's own tests + * pin, so a change to either side's derivation surfaces here rather than as two apps quietly + * colouring the same person differently. + * + * Compared as 8-bit hex, which is what iOS pins. The two platforms carry HSV out to different float + * types, so equal colours are equal once quantized to a channel byte and not before. + * + * Only the first two stops are pinned. iOS deliberately omits the third stop's WCAG correction, + * and a quote uses neither. + */ +class ComplementaryPaletteParityTest { + + @Test + fun `the palette matches the values iOS pins`() { + val first = assertNotNull(generateComplementaryColorPalette(IOS_IDENTIFIER_ONE)) + assertEquals("#D69336", first.first.hex()) + assertEquals("#D9CC3E", first.second.hex()) + + val second = assertNotNull(generateComplementaryColorPalette(IOS_IDENTIFIER_TWO)) + assertEquals("#D936CB", second.first.hex()) + assertEquals("#D93E98", second.second.hex()) + } + + private fun Color.hex(): String = "#%06X".format(toArgb() and 0xFFFFFF) + + private companion object { + // Byte-for-byte the identifiers iOS's own palette test uses: the UUIDs + // 8B3D4E1A-0000-4000-8000-000000000007 and the all-zero UUID, in the order + // `withUnsafeBytes(of: id.uuid)` hands them to SHA-512. + val IOS_IDENTIFIER_ONE: List = listOf( + 0x8B.toByte(), 0x3D, 0x4E, 0x1A, 0x00, 0x00, 0x40, 0x00, + 0x80.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, + ) + val IOS_IDENTIFIER_TWO: List = List(16) { 0.toByte() } + } +} From a98d7f292caabb0cccc988b7d04c0ea827bd2bf7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:11:17 -0400 Subject: [PATCH 06/18] feat(chat): add the quote panel both reply surfaces render One composable for the composer strip and the in-bubble panel, so the two cannot drift. A quoted payment shows its flag, amount and token name rather than a bare number, and the snippet runs to two lines because one truncates most quoted sentences mid-clause. Styling is confined to this file and one defaults object: the iOS reply UI is still pending design review. --- .../core/src/main/res/values/strings.xml | 3 + .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 127 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index ca298daae..ba87b4512 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -908,6 +908,9 @@ Clear selection Delete For Everyone Cancel edit + Reply + Cancel reply + Replying to %1$s Confirm edit Delete Message? This can\'t be undone diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt new file mode 100644 index 000000000..203809cdf --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -0,0 +1,127 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.getcode.opencode.compose.LocalExchange +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 +import com.getcode.ui.components.PriceWithFlag +import com.getcode.ui.core.addIf + +/** + * A citation of another message: an accent bar, the author's name, and a snippet of what they said. + * + * The same composable serves the composer strip and the panel inside a sent bubble, so the two can + * never drift. Every styling decision lives here and in [QuotePanelDefaults] — the iOS reply UI is + * still pending design review, and this keeps that review a one-file change. + */ +@Composable +fun ChatQuotePanel( + quote: ChatQuote, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val accent = quote.accent ?: CodeTheme.colors.tertiary + + Row( + modifier = modifier + .clip(QuotePanelDefaults.shape) + // A white scrim rather than the theme's own ground: the panel sits inside a bubble + // that is already drawn on that ground, so it would otherwise be invisible. + .background(White10) + .addIf(onClick != null) { Modifier.clickable { onClick?.invoke() } } + .padding(QuotePanelDefaults.padding), + horizontalArrangement = Arrangement.spacedBy(QuotePanelDefaults.gap), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(QuotePanelDefaults.accentWidth) + .height(QuotePanelDefaults.accentHeight) + .clip(RoundedCornerShape(QuotePanelDefaults.accentWidth / 2)) + .background(accent), + ) + + Column(verticalArrangement = Arrangement.spacedBy(QuotePanelDefaults.nameGap)) { + Text( + text = quote.authorName, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), + color = accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + when (val snippet = quote.snippet) { + // Two lines, not one: one truncates most quoted sentences mid-clause. + is ChatQuoteSnippet.Text -> Text( + text = snippet.body, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = QuotePanelDefaults.snippetMaxLines, + overflow = TextOverflow.Ellipsis, + ) + + // A quoted payment shows what identifies it, not just a number. + is ChatQuoteSnippet.Cash -> { + val exchange = LocalExchange.current + val currencyCode = snippet.amount.currencyCode.name + Row( + horizontalArrangement = Arrangement.spacedBy(QuotePanelDefaults.nameGap), + verticalAlignment = Alignment.CenterVertically, + ) { + PriceWithFlag( + amount = snippet.amount.formatted(), + currencyCode = currencyCode, + iconSize = QuotePanelDefaults.flagSize, + flag = exchange.getFlagByCurrency(currencyCode), + text = { formatted -> + Text( + text = formatted, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + }, + ) + Text( + text = snippet.tokenName, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +/** Every visual decision the panel makes, in one place for the pending design review. */ +private object QuotePanelDefaults { + val shape = RoundedCornerShape(8.dp) + val padding = 8.dp + val gap = 8.dp + val nameGap = 2.dp + val accentWidth = 3.dp + val accentHeight = 32.dp + val flagSize = 14.dp + const val snippetMaxLines = 2 +} From 523c5209d8bcd72720f902f06fb97ace6e61cbee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:12:14 -0400 Subject: [PATCH 07/18] feat(chat): render a reply as a text bubble with a citation Routes MessageContent.Reply through TextBubble rather than giving it a bubble of its own, so its grouping, edited marker and link handling stay identical to any other message. The quote's tap is dropped while the selection backdrop is up, as the cash bubble's target already is. The panel wraps its content rather than filling the bubble: a short reply to a long message should not stretch to the full bubble width. --- .../flipcash/shared/chat/models/ChatAction.kt | 9 ++++ .../flipcash/shared/chat/ui/MessageBubble.kt | 48 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 3aa23686c..506062d1b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -24,6 +24,15 @@ sealed interface ChatAction { /** Abandons an edit in progress, as a tap on the backdrop behind the edited message does. */ data object CancelEdit : ChatAction + + /** Scrolls the transcript to the message a quote cites. */ + data class JumpToMessage(val messageId: Long) : ChatAction + + /** Opens the composer's reply strip for [bubble]. */ + data class ReplyTo(val bubble: ChatListItem.ContentBubble) : ChatAction + + /** Takes the reply strip back down, leaving the draft where it is. */ + data object CancelReply : ChatAction } typealias ChatActionHandler = (ChatAction) -> Unit diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 8eccb3f86..9382fdd37 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -55,6 +55,7 @@ import com.flipcash.app.core.ui.TokenIconWithName import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.SeparatorConfig @@ -139,9 +140,27 @@ fun ContentBubble( }, ) + // A reply is a text bubble with a citation above the body. Routing it through + // the same composable keeps its grouping, edited marker and link handling + // identical to any other message, which is what it is. + is MessageContent.Reply -> TextBubble( + modifier = modifier, + text = content.content.filterIsInstance() + .firstOrNull()?.text.orEmpty(), + isFromSelf = item.isFromSelf, + position = position, + maxWidth = bubbleMaxWidth, + isEdited = item.isEdited, + quote = item.quote, + // Dropped with the backdrop up, as the cash bubble's target is: the tap + // should dismiss the backdrop, not jump the transcript out from under it. + onQuoteClick = item.quote?.takeIf { interactive }?.let { quote -> + { actionHandler(ChatAction.JumpToMessage(quote.messageId)) } + }, + ) + // TODO is MessageContent.Media -> Unit - is MessageContent.Reply -> Unit is MessageContent.System -> Unit } } @@ -150,6 +169,9 @@ fun ContentBubble( private const val EDITED_MARKER_SLOT = "edited-marker" +/** Space between a reply's citation and its body. */ +private val QUOTE_GAP = 6.dp + @Composable private fun TextBubble( text: String, @@ -159,6 +181,8 @@ private fun TextBubble( modifier: Modifier = Modifier, isEdited: Boolean = false, isTombstone: Boolean = false, + quote: ChatQuote? = null, + onQuoteClick: (() -> Unit)? = null, ) { Bubble(isFromSelf, position, maxWidth, modifier) { val linkStyle = SpanStyle( @@ -223,12 +247,22 @@ private fun TextBubble( // No SelectionContainer: long-press is the transcript's selection gesture, and a text // selection handle inside the bubble would consume it before the row ever sees it. Copying // a message is the selection bar's Copy action instead — the same trade WhatsApp makes. - Text( - text = laidOut, - inlineContent = inlineContent, - style = bodyStyle, - color = bodyColor, - ) + // The citation sits inside the bubble, above the body, so the two move together and the + // reply reads as one message rather than as a quote with a message under it. The panel + // wraps its content rather than filling the bubble: a one-word reply to a long message + // should not stretch to the full bubble width. + Column(verticalArrangement = Arrangement.spacedBy(QUOTE_GAP)) { + if (quote != null) { + ChatQuotePanel(quote = quote, onClick = onQuoteClick) + } + + Text( + text = laidOut, + inlineContent = inlineContent, + style = bodyStyle, + color = bodyColor, + ) + } if (isEdited) { Text( From 2160d7f1d7173624f21c49601ea8b07a1ece0e94 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:14:26 -0400 Subject: [PATCH 08/18] feat(chat): hold the reply target in composer state Reply and edit are mutually exclusive, since both own the composer. Unlike an edit a reply stashes no draft: the draft is the reply, and taking it away at the moment the user decided to send it is the opposite of what they asked for. The strip is cleared by the send handler rather than by the reducer. dispatchEvent reduces before it emits, so clearing it in the reducer would empty it before the handler could read the target and the reply would go out as an ordinary message. --- .../app/messenger/internal/ChatViewModel.kt | 27 ++++++ .../internal/screens/MessengerScreen.kt | 10 ++ .../internal/ChatMessageActionReducerTest.kt | 94 +++++++++++++++++++ 3 files changed, 131 insertions(+) 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 df9e0ee70..996961a2f 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 @@ -24,6 +24,7 @@ import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.app.funding.PurchaseMethodController @@ -166,6 +167,17 @@ internal class ChatViewModel @Inject constructor( val selection: ChatListItem.ContentBubble? = null, /** The message the composer is editing, or `null` when it is composing a new one. */ val editing: EditingMessage? = null, + /** + * The message the composer is citing, or `null` when it is composing an ordinary message. + * + * Mutually exclusive with [editing]: an edit takes the composer over with the message's own + * body, so citing another message from inside one would send a reply that overwrites a + * third. Unlike [EditingMessage] this stashes no draft — the draft is the reply. + * + * Cleared by the send handler rather than by the reducer: dispatchEvent reduces before it + * emits, so a reducer that cleared it would empty it before the handler could read it. + */ + val replyingTo: ChatQuote? = null, /** * True while the delete confirmation is up. * @@ -263,6 +275,10 @@ internal class ChatViewModel @Inject constructor( data object SubmitEdit : Event data object CancelEdit : Event data object EditingEnded : Event + + /** Opens the composer's reply strip on an already-resolved citation. */ + data class ReplyToMessage(val quote: ChatQuote) : Event + data object CancelReply : Event } @OptIn(ExperimentalCoroutinesApi::class) @@ -1227,6 +1243,7 @@ internal class ChatViewModel @Inject constructor( state.copy( selection = null, confirmingDelete = false, + replyingTo = null, editing = EditingMessage( messageId = event.messageId, originalText = event.text, @@ -1243,6 +1260,16 @@ internal class ChatViewModel @Inject constructor( Event.SubmitEdit -> { state -> state } Event.CancelEdit -> { state -> state } Event.EditingEnded -> { state -> state.copy(editing = null) } + is Event.ReplyToMessage -> { state -> + state.copy( + replyingTo = event.quote, + selection = null, + confirmingDelete = false, + // Reply and edit both own the composer, so opening one closes the other. + editing = null, + ) + } + Event.CancelReply -> { state -> state.copy(replyingTo = null) } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index a7393fd6e..7b0e1964b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -81,6 +81,16 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit) } + // Both reply entry points land on one event so the citation is resolved in one place; + // turning a bubble into a quote needs a database read. Wired in a later change. + is ChatAction.ReplyTo -> Unit + + ChatAction.CancelReply -> { + viewModel.dispatchEvent(ChatViewModel.Event.CancelReply) + } + + is ChatAction.JumpToMessage -> Unit + is ChatAction.ViewProfile -> { // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs // (see State.canViewProfile), so no gating is needed here. diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index bbc557ea3..230648500 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -5,6 +5,8 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.MessagePolicy import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -221,4 +223,96 @@ class ChatMessageActionReducerTest { assertNotNull(reduce(editing, ChatViewModel.Event.SubmitEdit).editing) assertNotNull(reduce(editing, ChatViewModel.Event.CancelEdit).editing) } + + private fun quote(messageId: Long = 4) = ChatQuote( + messageId = messageId, + authorName = "Ada", + snippet = ChatQuoteSnippet.Text("the original"), + accent = null, + ) + + @Test + fun `replying opens the strip and clears the selection`() { + val target = bubble(1) + val selected = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ToggleMessageSelection(target), + ) + + val state = reduce(selected, ChatViewModel.Event.ReplyToMessage(quote())) + + assertNull(state.selection) + assertEquals(quote(), state.replyingTo) + } + + /** + * Unlike an edit, a reply leaves the composer alone: the draft is the reply. Stashing it the + * way EditingMessage does would take the user's half-written text away at the moment they + * decided to send it. + */ + @Test + fun `replying leaves the draft in the composer`() { + val state = reduce( + ChatViewModel.State(chatInputState = TextFieldState("half-written")), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + assertEquals("half-written", state.chatInputState.text.toString()) + } + + @Test + fun `starting an edit takes the reply strip down`() { + val replying = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + val state = reduce(replying, ChatViewModel.Event.EditMessage(1, "hello")) + + assertNull(state.replyingTo) + assertNotNull(state.editing) + } + + @Test + fun `replying takes an edit down`() { + val editing = reduce( + ChatViewModel.State(), + ChatViewModel.Event.EditMessage(1, "hello"), + ) + + val state = reduce(editing, ChatViewModel.Event.ReplyToMessage(quote())) + + assertNull(state.editing) + assertNotNull(state.replyingTo) + } + + @Test + fun `cancelling the reply keeps the draft`() { + val replying = reduce( + ChatViewModel.State(chatInputState = TextFieldState("half-written")), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + val state = reduce(replying, ChatViewModel.Event.CancelReply) + + assertNull(state.replyingTo) + assertEquals("half-written", state.chatInputState.text.toString()) + } + + /** + * The send handler reads the target off state and clears the strip itself, the same way it + * clears the composer's text. Clearing it here would empty it before the handler ran and send + * the reply as an ordinary message: dispatchEvent reduces before it emits. + */ + @Test + fun `sending leaves the reply in place for the handler to read`() { + val replying = reduce( + ChatViewModel.State(), + ChatViewModel.Event.ReplyToMessage(quote()), + ) + + val state = reduce(replying, ChatViewModel.Event.SendMessage) + + assertEquals(quote(), state.replyingTo) + } } From 626119006e5f02950d1f71ce867f76f3f71cba32 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:16:45 -0400 Subject: [PATCH 09/18] feat(chat): resolve a reply's citation in the transcript pipeline Resolved next to the token-metadata lookup, the one place in the transcript that already does async per-item work. A citation of a message this device never stored resolves to null and renders no panel rather than an error. The accent derives from the cited message's own sender id: ChatParticipant.Contact wraps a device contact and carries no user id, so the participant cannot colour a counterparty. The send handler reads the reply target off state and takes the strip down itself, alongside clearing the draft, because dispatchEvent reduces before it emits. ChatCoordinator gains getMessage and distanceFromNewest, both local reads, to serve the citation and the jump. --- .../app/messenger/internal/ChatViewModel.kt | 59 ++++++++++++++++++- .../flipcash/shared/chat/ChatCoordinator.kt | 14 +++++ .../internal/delegates/MessagingDelegate.kt | 6 ++ 3 files changed, 78 insertions(+), 1 deletion(-) 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 996961a2f..06bdb6309 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 @@ -25,6 +25,7 @@ import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.app.funding.PurchaseMethodController @@ -34,6 +35,7 @@ import com.flipcash.features.messenger.R import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.DeliveryStatus import com.flipcash.services.models.chat.MessageContent @@ -58,6 +60,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token +import com.getcode.ui.utils.generateComplementaryColorPalette import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace import com.getcode.view.BaseViewModel @@ -320,6 +323,16 @@ internal class ChatViewModel @Inject constructor( } else content } else content + // Resolved here, next to the token-metadata lookup, because this is the one + // place in the transcript that already does async per-item work. A citation of + // a message this device never stored resolves to null, and the bubble renders + // its body with no panel rather than an error. + val quote = (content as? MessageContent.Reply)?.let { reply -> + stateFlow.value.chatId + ?.let { chatCoordinator.getMessage(it, reply.repliedMessageId) } + ?.toQuote() + } + val receiptStatus = if (message.isFromSelf) { when (message.deliveryStatus) { DeliveryStatus.SENDING -> ReceiptStatus.SENDING @@ -344,6 +357,7 @@ internal class ChatViewModel @Inject constructor( // taxonomy becomes another input to the resolver rather than a branch at // each action site. capabilities = resolveCapabilities(message, policy), + quote = quote, ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -354,6 +368,45 @@ internal class ChatViewModel @Inject constructor( } } + /** + * The citation shown for [this] message. + * + * The accent comes from the message's own sender id, not from `State.participant`: + * [ChatParticipant.Contact] wraps a device contact and carries no user id, so the participant + * is not a usable source for a counterparty's colour. + */ + private suspend fun ChatMessage.toQuote(): ChatQuote { + val body = content.firstOrNull() + return ChatQuote( + messageId = messageId, + authorName = if (isFromSelf) { + resources.getString(R.string.title_you) + } else { + stateFlow.value.participant?.name.orEmpty() + }, + snippet = when (body) { + is MessageContent.Cash -> ChatQuoteSnippet.Cash( + amount = body.amount, + tokenName = body.tokenName.ifBlank { + tokenCoordinator.getTokenMetadata(body.mint) + .getOrNull()?.token?.name.orEmpty() + }, + ) + + is MessageContent.Text -> ChatQuoteSnippet.Text(body.text) + + // A reply to a reply cites the inner body, not the nested citation. + is MessageContent.Reply -> ChatQuoteSnippet.Text( + body.content.filterIsInstance() + .firstOrNull()?.text.orEmpty() + ) + + else -> ChatQuoteSnippet.Text("") + }, + accent = senderId?.let { generateComplementaryColorPalette(it)?.first }, + ) + } + private val maxAmountFlow by lazy { combine( transactionController.limits, @@ -835,11 +888,15 @@ internal class ChatViewModel @Inject constructor( val chatId = stateFlow.value.chatId ?: return@onEach if (textToSend.isBlank()) return@onEach val chatType = stateFlow.value.chatType + // Read here, not in the reducer: the reply strip comes down with the draft, and + // both are the composer emptying itself once the message is on its way. + val replyToMessageId = stateFlow.value.replyingTo?.messageId stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("") + if (replyToMessageId != null) dispatchEvent(Event.CancelReply) viewModelScope.launch { - chatCoordinator.sendMessage(chatId, textToSend) + chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId) .onSuccess { trace("message sent successfully") analytics.messageSentInChat(type = chatType) 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 57280883b..c1828277d 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 @@ -137,6 +137,20 @@ interface MessagingOperations { /** Observes messages in [chatId] via Paging 3, with remote-mediated page loads. */ fun observeMessagesPaged(chatId: ChatId): Flow> + /** + * The stored message [messageId] in [chatId], or `null` if this device has never stored it. + * + * A local read only. A reply citing a message from before this device's history resolves to + * `null`, which the transcript renders as a reply with no citation rather than as an error. + */ + suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? + + /** + * How far back [messageId] sits from the newest message in [chatId], or `null` when this + * device has not stored it. Bounds the walk that scrolls a quote's citation into view. + */ + suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? + /** Observes the member list for [chatId]. */ fun observeMembers(chatId: ChatId): Flow> 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 95591b82e..ce2a871c2 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 @@ -152,6 +152,12 @@ class MessagingDelegate @Inject constructor( } } + override suspend fun getMessage(chatId: ChatId, messageId: Long): ChatMessage? = + messageDataSource.getMessage(chatId, messageId) + + override suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? = + messageDataSource.distanceFromNewest(chatId, messageId) + override fun observeMembers(chatId: ChatId): Flow> { return memberDataSource.observeMembers(chatId) } From 3669d0314be4ff97f5476b13647c555bbb6f1346 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:20:49 -0400 Subject: [PATCH 10/18] feat(chat): offer Reply first in the message selection bar Order is priority in this bar: the first actions keep their icons when the bar runs out of room. Reply leads because it is the most common action and the only one a cash bubble offers, so burying it is what would leave that bubble's bar empty. Both entry points dispatch ReplyRequested with the bubble rather than a citation. Building a citation reads the stored message, so the resolve lives in one handler; a message this device never stored drops the request instead of opening an empty strip. --- .../app/messenger/internal/ChatViewModel.kt | 24 +++++++++++++++++++ .../internal/screens/MessengerScreen.kt | 6 +++-- .../internal/screens/components/ChatTopBar.kt | 18 +++++++++++--- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 06bdb6309..a1135f495 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 @@ -279,6 +279,13 @@ internal class ChatViewModel @Inject constructor( data object CancelEdit : Event data object EditingEnded : Event + /** + * Asks for a reply to [bubble]. Both entry points — the selection bar and the swipe — land + * here rather than on [ReplyToMessage], because turning a bubble into a citation needs the + * stored message, and that read belongs in one place. + */ + data class ReplyRequested(val bubble: ChatListItem.ContentBubble) : Event + /** Opens the composer's reply strip on an already-resolved citation. */ data class ReplyToMessage(val quote: ChatQuote) : Event data object CancelReply : Event @@ -872,6 +879,18 @@ internal class ChatViewModel @Inject constructor( ) } .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. + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + val stored = chatCoordinator.getMessage(chatId, event.bubble.messageId) + ?: return@onEach + dispatchEvent(Event.ReplyToMessage(stored.toQuote())) + } + .launchIn(viewModelScope) } /** Leaves edit mode, restoring the draft the edit interrupted. */ @@ -1317,6 +1336,11 @@ internal class ChatViewModel @Inject constructor( Event.SubmitEdit -> { state -> state } Event.CancelEdit -> { state -> state } Event.EditingEnded -> { state -> state.copy(editing = null) } + // The strip opens on ReplyToMessage, once the citation resolves; all this does is + // take the selection bar down so the transcript is legible while that read runs. + is Event.ReplyRequested -> { state -> + state.copy(selection = null, confirmingDelete = false) + } is Event.ReplyToMessage -> { state -> state.copy( replyingTo = event.quote, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 7b0e1964b..26e2e5b50 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -82,8 +82,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } // Both reply entry points land on one event so the citation is resolved in one place; - // turning a bubble into a quote needs a database read. Wired in a later change. - is ChatAction.ReplyTo -> Unit + // turning a bubble into a quote needs a database read. + is ChatAction.ReplyTo -> { + viewModel.dispatchEvent(ChatViewModel.Event.ReplyRequested(action.bubble)) + } ChatAction.CancelReply -> { viewModel.dispatchEvent(ChatViewModel.Event.CancelReply) 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 65df4cc67..fbeed4bd0 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Reply import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit @@ -198,10 +199,21 @@ private fun MessageSelectionBar( val capabilities = selection.capabilities val body = selection.plainText - // Order is priority: the first actions keep their icons when the bar runs out of room. Delete - // leads because burying the one action with a confirmation behind a menu makes it a three-tap - // job, and it is the action WhatsApp keeps inline too. + // Order is priority: the first actions keep their icons when the bar runs out of room. Reply + // leads because it is the most common action and the only one a cash bubble offers — burying it + // is the one choice that would leave that bubble's bar empty. Delete follows: putting the one + // action with a confirmation behind a menu makes it a three-tap job. val actions = buildList { + if (MessageCapability.Reply in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_reply), + icon = Icons.AutoMirrored.Outlined.Reply, + testTag = "action_reply_message", + onClick = { dispatch(ChatViewModel.Event.ReplyRequested(selection)) }, + ) + ) + } if (MessageCapability.Delete in capabilities) { add( MessageAction( From fdfb23653bc4d497a9cbc75f63f279159b83cbed Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:22:06 -0400 Subject: [PATCH 11/18] feat(chat): show what is being replied to above the composer A banner rather than the leading-control swap an edit uses. The two differ in what the user needs to see: an edit's subject is already in front of them as the composer's text, while a reply's subject is a different message that is very likely scrolled off screen. Gated on canType, so a chat that cannot take text never shows a reply strip over a bar with nothing to send from. The composer claims focus when the strip opens, for the same reason an edit does: neither entry point raises the keyboard. --- .../screens/components/ChatBottomBar.kt | 179 +++++++++++------- 1 file changed, 112 insertions(+), 67 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index d9647319d..4f96fcf95 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -48,6 +48,8 @@ import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme +import com.flipcash.shared.chat.ui.ChatQuotePanel +import com.getcode.ui.components.CircularIconButton import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit import com.getcode.ui.components.chat.TypingIndicator @@ -158,80 +160,123 @@ internal fun UserControlBottomBar( ) }, ) { canType -> - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.Bottom, - ) { - // Editing swaps the leading control rather than adding a banner above the bar: - // send-cash is not reachable mid-edit anyway, and cancel is what the slot is - // for while the edit is open. - if (state.editing != null) { - CancelEditButton( - onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, - ) - } else { - SendCashButton( - state = state, - hazeState = hazeState, - hazeMaterial = material, - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.OnSendCash) - } - } - ) - } - - if (canType) { - ChatInput( + Column(modifier = Modifier.fillMaxWidth()) { + // A banner, unlike an edit, which swaps the leading control instead. The two differ + // in what the user needs to see: an edit's subject is already in front of them as + // the composer's text, while a reply's subject is a different message that is very + // likely scrolled off screen. Gated on canType so a reply strip never sits above a + // bar with nothing to send from. + state.replyingTo?.takeIf { canType }?.let { quote -> + Row( modifier = Modifier - .testTag("chat_message_input") - .weight(1f) - .border( - CodeTheme.dimens.border, - CodeTheme.colors.divider, - CodeTheme.shapes.medium, + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + ChatQuotePanel( + quote = quote, + modifier = Modifier + .weight(1f) + .testTag("composer_reply_strip"), + ) + CircularIconButton( + onClick = { dispatch(ChatViewModel.Event.CancelReply) }, + testTag = "action_cancel_reply", + ) { size -> + Icon( + modifier = Modifier.requiredSize(size), + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.action_cancelReply), + tint = Color.White, ) - .hazeBlur(HazeInput.Sources(hazeState), material), - focusRequester = focusRequester, - hint = "Message", - state = state.chatInputState, - // One read of the edit state decides both the glyph and what the tap - // does, so the composer cannot show a checkmark and send a new message. - submit = if (state.editing != null) { - ChatInputSubmit.ConfirmEdit { - dispatch(ChatViewModel.Event.SubmitEdit) - keyboard.restartInput() + } + } + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.Bottom, + ) { + // Editing swaps the leading control rather than adding a banner above the bar: + // send-cash is not reachable mid-edit anyway, and cancel is what the slot is + // for while the edit is open. + if (state.editing != null) { + CancelEditButton( + onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, + ) + } else { + SendCashButton( + state = state, + hazeState = hazeState, + hazeMaterial = material, + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.OnSendCash) + } } - } else { - ChatInputSubmit.Send { - dispatch(ChatViewModel.Event.SendMessage) - keyboard.restartInput() + ) + } + + if (canType) { + ChatInput( + modifier = Modifier + .testTag("chat_message_input") + .weight(1f) + .border( + CodeTheme.dimens.border, + CodeTheme.colors.divider, + CodeTheme.shapes.medium, + ) + .hazeBlur(HazeInput.Sources(hazeState), material), + focusRequester = focusRequester, + hint = "Message", + state = state.chatInputState, + // One read of the edit state decides both the glyph and what the tap + // does, so the composer cannot show a checkmark and send a new message. + submit = if (state.editing != null) { + ChatInputSubmit.ConfirmEdit { + dispatch(ChatViewModel.Event.SubmitEdit) + keyboard.restartInput() + } + } else { + ChatInputSubmit.Send { + dispatch(ChatViewModel.Event.SendMessage) + keyboard.restartInput() + } + }, + ) + + // An edit starts from a long-press, which leaves the keyboard down, so the + // composer has to claim focus itself or the pre-filled text sits unreachable. + LaunchedEffect(state.editing?.messageId) { + if (state.editing != null) { + focusRequester.requestFocus() + keyboard.show() } - }, - ) + } - // An edit starts from a long-press, which leaves the keyboard down, so the - // composer has to claim focus itself or the pre-filled text sits unreachable. - LaunchedEffect(state.editing?.messageId) { - if (state.editing != null) { - focusRequester.requestFocus() - keyboard.show() + // A reply starts from a long-press or a swipe, neither of which raises the + // keyboard, so the composer claims focus for the same reason. + LaunchedEffect(state.replyingTo?.messageId) { + if (state.replyingTo != null) { + focusRequester.requestFocus() + keyboard.show() + } } - } - // Restores the pre-#1075 behavior: when OnStartMessageInput raises - // state.messageInputRequested (returning from amount entry after a send, or a - // post-tip open), focus the input and show the keyboard. Co-located with - // ChatInput so focusRequester is guaranteed attached; consumes the request so - // it fires once and a later manual dismiss doesn't re-open it. - LaunchedEffect(state.messageInputRequested) { - if (state.messageInputRequested) { - focusRequester.requestFocus() - keyboard.show() - dispatch(ChatViewModel.Event.OnMessageInputConsumed) + // Restores the pre-#1075 behavior: when OnStartMessageInput raises + // state.messageInputRequested (returning from amount entry after a send, or a + // post-tip open), focus the input and show the keyboard. Co-located with + // ChatInput so focusRequester is guaranteed attached; consumes the request so + // it fires once and a later manual dismiss doesn't re-open it. + LaunchedEffect(state.messageInputRequested) { + if (state.messageInputRequested) { + focusRequester.requestFocus() + keyboard.show() + dispatch(ChatViewModel.Event.OnMessageInputConsumed) + } } } } From cd34c596f9f3c1ffede8a767dc7645571620e08b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:25:24 -0400 Subject: [PATCH 12/18] feat(chat): swipe a message to reply to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from the legacy MessageNode with its numbers intact: a threshold at 40% of the row's width, a positional threshold of 0.9, an infinite velocity threshold, and an offset capped at 30%. The row never settles open — the haptic fires when the threshold is crossed and the action fires as the row springs back, so an abandoned drag costs nothing. That refusal comes from confirmValueChange, which the current AnchoredDraggableState constructor drops, so the port keeps the deprecated one: without the veto a fling past the threshold settles the row open, a state the gesture has no way back out of. --- .../internal/screens/components/MessageRow.kt | 13 ++- .../screens/components/SwipeToReply.kt | 107 ++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index bb1f0d7d4..72b4b5620 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler @@ -157,7 +158,17 @@ internal fun MessageRow( // dismiss but the keyboard. onClick = { keyboard.hide() }, ) - }, + } + // No swipe with the backdrop up either, and for the same reason: the bar is already + // acting on a message. + .then( + rememberSwipeToReply( + enabled = bubble != null && + !selecting && + MessageCapability.Reply in bubble.capabilities, + onReply = { bubble?.let { onAction(ChatAction.ReplyTo(it)) } }, + ) + ), ) { when (item) { is ChatListItem.DateSeparator -> Box(insertionModifier) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt new file mode 100644 index 000000000..65ef53b0e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt @@ -0,0 +1,107 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.getcode.util.vibration.LocalVibrator +import kotlin.math.roundToInt + +internal enum class ReplyDragAnchor { Rest, Reply } + +/** + * A trailing-ward drag on a message row that dispatches a reply. + * + * Ported from the legacy `MessageNode`, including its numbers. The row never settles open — + * `confirmValueChange` always refuses — so the gesture is a pull that springs back: the haptic fires + * the moment the threshold is crossed, and the action fires as the row returns, which is what makes + * an abandoned drag cost nothing. + */ +@Composable +internal fun rememberSwipeToReply( + enabled: Boolean, + onReply: () -> Unit, +): Modifier { + if (!enabled) return Modifier + + val density = LocalDensity.current + val vibrator = LocalVibrator.current + // The screen width, not the row's own. A row spans the transcript's full width, and reading it + // from BoxWithConstraints would mean building this modifier inside the constraints scope — + // which is not where the row's outer Box applies its modifiers. + val widthPx = with(density) { LocalConfiguration.current.screenWidthDp.dp.toPx() } + val threshold = widthPx * SWIPE_THRESHOLD_FRACTION + var crossed by remember { mutableStateOf(false) } + + val anchors = remember(threshold) { + DraggableAnchors { + ReplyDragAnchor.Rest at 0f + ReplyDragAnchor.Reply at threshold + } + } + + // The deprecated constructor, deliberately. Its replacement drops confirmValueChange, and that + // veto is the whole gesture: without it a fling past the threshold settles the row open, which + // is a state this interaction has no way back out of. + @Suppress("DEPRECATION") + val dragState = remember(anchors) { + AnchoredDraggableState( + initialValue = ReplyDragAnchor.Rest, + anchors = anchors, + positionalThreshold = { it * 0.9f }, + velocityThreshold = { Float.POSITIVE_INFINITY }, + confirmValueChange = { target -> + if (target == ReplyDragAnchor.Reply && !crossed) { + crossed = true + vibrator.tick() + } + false + }, + snapAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + decayAnimationSpec = splineBasedDecay(density), + ) + } + + LaunchedEffect(crossed, dragState.targetValue) { + if (crossed && + dragState.targetValue == ReplyDragAnchor.Rest && + dragState.isAnimationRunning + ) { + onReply() + crossed = false + } + } + + return Modifier + .anchoredDraggable( + state = dragState, + orientation = Orientation.Horizontal, + ) + .offset { + IntOffset( + x = dragState.offset.coerceIn(0f, widthPx * MAX_OFFSET_FRACTION).roundToInt(), + y = 0, + ) + } +} + +private const val SWIPE_THRESHOLD_FRACTION = 0.40f +private const val MAX_OFFSET_FRACTION = 0.30f From 75ce2fa8b533f798c89aa0b26630b49f262e5c94 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:29:54 -0400 Subject: [PATCH 13/18] feat(chat): jump to the message a quote cites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks the append path rather than using PagingConfig.jumpThreshold. A jump there routes through PageFetcher::refresh with triggerRemoteRefresh set, so every tap would fire a RemoteMediator refresh — token = null and a fetch of the newest page — to reach a message already in the database. The walk is local: RemoteMediator.load(APPEND) fires only once the PagingSource runs dry, and a citation that could not be resolved renders no panel to tap. The view model resolves distanceFromNewest before handing the target over, so a walk that cannot reach its message never starts, and one that can is bounded by how far back the message actually sits. With the index from the first commit, reaching a message 2,000 rows back measured 20 ms. --- .../app/messenger/internal/ChatViewModel.kt | 38 +++++++++++ .../internal/screens/MessengerScreen.kt | 5 +- .../screens/components/MessageList.kt | 68 +++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) 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 a1135f495..011cb62d6 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 @@ -181,6 +181,18 @@ internal class ChatViewModel @Inject constructor( * emits, so a reducer that cleared it would empty it before the handler could read it. */ val replyingTo: ChatQuote? = null, + /** + * A message the transcript has been asked to scroll to, held until the list consumes it. + * + * State rather than a one-shot event, for the reason [messageInputRequested] is: eventFlow + * is replay-0, so a request raised while the list is recomposing would be dropped. + */ + val jumpTarget: Long? = null, + /** + * How far back [jumpTarget] sits from the newest message, which bounds the walk that loads + * it. Set alongside [jumpTarget] and cleared with it. + */ + val jumpBudget: Int? = null, /** * True while the delete confirmation is up. * @@ -289,6 +301,13 @@ internal class ChatViewModel @Inject constructor( /** Opens the composer's reply strip on an already-resolved citation. */ data class ReplyToMessage(val quote: ChatQuote) : Event data object CancelReply : Event + + /** Asks the transcript to scroll to [messageId] — a tap on a quote. */ + data class JumpToMessage(val messageId: Long) : Event + + /** The same request, once the walk's bound is known. */ + data class JumpResolved(val messageId: Long, val budget: Int) : Event + data object JumpConsumed : Event } @OptIn(ExperimentalCoroutinesApi::class) @@ -891,6 +910,18 @@ internal class ChatViewModel @Inject constructor( dispatchEvent(Event.ReplyToMessage(stored.toQuote())) } .launchIn(viewModelScope) + + // A distance the device cannot measure is a message it never stored, and no walk would + // reach it. Resolving here rather than in the list keeps the read off the composition and + // gives the walk a bound before it starts. + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + val distance = chatCoordinator.distanceFromNewest(chatId, event.messageId) + ?: return@onEach + dispatchEvent(Event.JumpResolved(event.messageId, distance)) + } + .launchIn(viewModelScope) } /** Leaves edit mode, restoring the draft the edit interrupted. */ @@ -1351,6 +1382,13 @@ internal class ChatViewModel @Inject constructor( ) } Event.CancelReply -> { state -> state.copy(replyingTo = null) } + // The request itself changes nothing: the target is only worth holding once the + // walk's bound resolves, and that read is what decides whether it can be reached. + is Event.JumpToMessage -> { state -> state } + is Event.JumpResolved -> { state -> + state.copy(jumpTarget = event.messageId, jumpBudget = event.budget) + } + Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 26e2e5b50..3c6aea68c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -91,7 +91,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.CancelReply) } - is ChatAction.JumpToMessage -> Unit + is ChatAction.JumpToMessage -> { + viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId)) + } is ChatAction.ViewProfile -> { // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs @@ -144,6 +146,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { otherReadPointer = otherReadPointer, onAction = chatActionHandler, canViewProfile = state.canViewProfile, + onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index f4e8aff4e..dd556a7c5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withTimeoutOrNull @Composable internal fun MessageList( @@ -57,6 +58,7 @@ internal fun MessageList( otherReadPointer: MessagePointer? = null, onAction: ChatActionHandler, canViewProfile: Boolean, + onJumpConsumed: () -> Unit = {}, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -114,6 +116,44 @@ internal fun MessageList( if (buried > 0) listState.animateScrollBy(buried.toFloat()) } + // Walking the append path, rather than PagingConfig.jumpThreshold: a jump there routes + // through PageFetcher::refresh with triggerRemoteRefresh set, so every tap would fire a + // RemoteMediator refresh — token = null and a fetch of the newest page — to reach a message + // already in the database. Appending stays local until the PagingSource runs dry. + LaunchedEffect(state.jumpTarget) { + val target = state.jumpTarget ?: return@LaunchedEffect + val budget = (state.jumpBudget?.plus(JUMP_PAGE_SIZE) ?: MAX_JUMP_ITEMS) + .coerceAtMost(MAX_JUMP_ITEMS) + + var index = indexOf(messages, target) + while (index == null && messages.loadedCount <= budget) { + // Touching the last loaded index is what emits the ViewportHint that drives one + // more append. One page at a time: with placeholders on, itemCount is the whole + // chat, so hinting at the end would append at the far side of history instead. + val hint = messages.appendHintIndex + if (hint < 0) break + + val before = messages.loadedCount + messages[hint] + + // Wait for that append to land rather than for a frame: one frame is not a + // guarantee, and a walk that races the pager gives up and scrolls nowhere. + // loadedCount grows either way — placeholders on, placeholdersAfter shrinks; off, + // itemCount grows. + val progressed = withTimeoutOrNull(JUMP_STEP_TIMEOUT_MS) { + snapshotFlow { messages.loadedCount to messages.loadState.append } + .first { (count, append) -> count > before || append.endOfPaginationReached } + .first > before + } + if (progressed != true) break + + index = indexOf(messages, target) + } + + index?.let { listState.animateScrollToItem(it) } + onJumpConsumed() + } + // Holds the focused message at the position it was long-pressed at, against the keyboard // shortening the list from below — whether the keyboard came up for the edit or because the // composer was tapped with the selection bar still showing. See FocusPin. @@ -319,3 +359,31 @@ internal fun MessageList( } } // CompositionLocalProvider } + +/** + * How many items are actually loaded, as opposed to standing in as placeholders. + * + * `itemCount` counts placeholders too, so it is the whole chat from the first page onward when + * placeholders are enabled — useless as a measure of what a walk has reached. + */ +private val LazyPagingItems.loadedCount: Int + get() = itemSnapshotList.items.size + +/** + * The first index the pager has nothing for — the hint that drives one more append. + * + * Clamped into range, so with placeholders off (where it is one past the end) it lands on the last + * loaded item instead, which emits the same hint. + */ +private val LazyPagingItems.appendHintIndex: Int + get() = (itemSnapshotList.placeholdersBefore + loadedCount).coerceAtMost(itemCount - 1) + +/** The presented index of [messageId], or `null` while it is still unloaded. */ +private fun indexOf(messages: LazyPagingItems, messageId: Long): Int? = + (0 until messages.itemCount).firstOrNull { i -> + (messages.peek(i) as? ChatListItem.ContentBubble)?.messageId == messageId + } + +private const val JUMP_PAGE_SIZE = 50 +private const val MAX_JUMP_ITEMS = 5_000 +private const val JUMP_STEP_TIMEOUT_MS = 2_000L From d5c3dfb53db110503190a688204d198acacdbce7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 22:31:04 -0400 Subject: [PATCH 14/18] test(chat): cover the jump request's two steps in the reducer The request and the target are separate states on purpose: a message this device never stored resolves to no distance, so the transcript is never asked to walk to something it cannot reach. --- .../internal/ChatMessageActionReducerTest.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index 230648500..30922cc2d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -315,4 +315,33 @@ class ChatMessageActionReducerTest { assertEquals(quote(), state.replyingTo) } + + /** + * The request only becomes a target once the walk's bound is known — a message this device + * never stored resolves to no distance and so never reaches the transcript. + */ + @Test + fun `a jump request alone sets no target`() { + val state = reduce(ChatViewModel.State(), ChatViewModel.Event.JumpToMessage(7)) + + assertNull(state.jumpTarget) + } + + @Test + fun `a resolved jump carries the target and its bound`() { + val state = reduce(ChatViewModel.State(), ChatViewModel.Event.JumpResolved(7, 240)) + + assertEquals(7L, state.jumpTarget) + assertEquals(240, state.jumpBudget) + } + + @Test + fun `consuming a jump clears both`() { + val jumping = reduce(ChatViewModel.State(), ChatViewModel.Event.JumpResolved(7, 240)) + + val state = reduce(jumping, ChatViewModel.Event.JumpConsumed) + + assertNull(state.jumpTarget) + assertNull(state.jumpBudget) + } } From 3368a07be9c3ce39f1f7554ee4fafcde6357d1ca Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 7 Sep 2026 23:32:14 -0400 Subject: [PATCH 15/18] test(chat): cover reply in the Maestro suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply had no UI coverage, and its two entry points — the selection bar and a trailing-ward swipe — reach the same composer strip by different paths, so a break in either was invisible. The flow types a draft, opens the strip from both entry points, then sends that same draft as the reply. One string doing both jobs is what distinguishes reply from edit — an edit stashes the composer, a reply leaves it — and it avoids `eraseText`, which clears back to wherever the tap put the cursor instead of emptying the field. Tag the bubble's quote panel as `bubble_reply_quote`: the citation repeats the quoted message's own text, so no text matcher can tell it from the bubble it cites. --- .../flipcash/shared/chat/ui/MessageBubble.kt | 9 +- maestro/README.md | 3 + maestro/chat_message_reply.yaml | 82 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 maestro/chat_message_reply.yaml diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 9382fdd37..0baec2b47 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder @@ -253,7 +254,13 @@ private fun TextBubble( // should not stretch to the full bubble width. Column(verticalArrangement = Arrangement.spacedBy(QUOTE_GAP)) { if (quote != null) { - ChatQuotePanel(quote = quote, onClick = onQuoteClick) + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + // Tagged because the citation repeats the quoted message's own text, so a + // UI test matching on that text cannot tell the two apart. + modifier = Modifier.testTag("bubble_reply_quote"), + ) } Text( diff --git a/maestro/README.md b/maestro/README.md index 36313d1da..8ebe32d1c 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -143,6 +143,9 @@ maestro/run.sh maestro/tipping_setup.yaml the unsent draft the edit displaced comes back on cancel and on confirm alike - `chat_message_delete.yaml` — delete for everyone: the confirmation sheet, Back leaving the message alone, and confirming taking it out of the transcript +- `chat_message_reply.yaml` — reply from the selection bar and from a trailing-ward swipe: the + strip leaves the draft alone where an edit stashes it, and the sent bubble carries a citation + that is tappable - `blocking.yaml` — block a chat participant from their profile, verify in My Account → Blocked, then unblock (leaves the account clean) - `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → presents the tip flow diff --git a/maestro/chat_message_reply.yaml b/maestro/chat_message_reply.yaml new file mode 100644 index 000000000..ee0d20e8c --- /dev/null +++ b/maestro/chat_message_reply.yaml @@ -0,0 +1,82 @@ +appId: com.flipcash.app.android +name: "Chat — reply to a message" +tags: + - smoke + - chat +--- +# Reply has two ways in — the selection bar and a trailing-ward swipe — and both open the same +# strip above the composer. Where an edit takes the composer over and stashes what was in it, a +# reply cites a different message and so leaves the draft alone; that is the discriminating +# assertion here. +# +# Fund-safe. Everything it cites and sends is its own, like chat_message_selection.yaml. +- runFlow: + file: subflows/login_with_flags.yaml + env: + BETA_FLAGS: "" +- runFlow: subflows/open_tip_chat.yaml + +- evalScript: ${output.body = 'e2e reply-to ' + Date.now()} +- evalScript: ${output.draft = 'e2e draft ' + Date.now()} + +- tapOn: { id: chat_message_input } +- inputText: "${output.body}" +- tapOn: { id: chat_send_icon } +- extendedWaitUntil: { visible: "${output.body}", timeout: 10000 } + +# An unsent draft for the strip to sit above rather than displace. +- tapOn: { id: chat_message_input } +- inputText: "${output.draft}" +- assertVisible: "${output.draft}" + +# Entry point one: the selection bar. Reply leads the bar, so it is inline unless the screen is +# narrow enough to push it under the overflow — the subflow handles either. +- runFlow: + file: subflows/select_own_message.yaml + env: + MESSAGE_BODY: "${output.body}" +- runFlow: + file: subflows/tap_message_action.yaml + env: + ACTION_ID: action_reply_message + ACTION_LABEL: "Reply" +- assertVisible: { id: composer_reply_strip } +- assertVisible: { id: action_cancel_reply } +# The composer is still a composer: the draft is untouched and send is still send, neither of +# which is true during an edit. +- assertVisible: "${output.draft}" +- assertVisible: { id: chat_send_icon } + +# Taking the strip down leaves the draft where it was. +- tapOn: { id: action_cancel_reply } +- assertNotVisible: { id: composer_reply_strip } +- assertVisible: "${output.draft}" + +# Entry point two: the swipe. The row never settles open — it springs back and dispatches on the +# way home — so there is nothing to undo afterwards, and the strip is the only evidence it fired. +- swipe: + from: + text: "${output.body}" + direction: RIGHT + duration: 400 +- extendedWaitUntil: { visible: { id: composer_reply_strip }, timeout: 5000 } + +# Sending with the strip up carries the citation. The draft is what goes out — it has sat through +# both entry points untouched, so sending it is the same assertion carried one step further. It is +# also the only way to be sure of what is in the composer: a tap puts the cursor where it lands, +# so `eraseText` clears back to that point and leaves the rest. +- tapOn: { id: chat_send_icon } + +# The citation renders on the optimistic row, before the server has answered. Asserted by id +# rather than by the quoted text, which is the original message's own body and so matches the +# bubble above as well. +- extendedWaitUntil: { visible: "${output.draft}", timeout: 10000 } +- assertVisible: { id: bubble_reply_quote } +- assertNotVisible: { id: composer_reply_strip } + +# The citation is live. This transcript is short enough that the cited message is already on +# screen, so what this checks is that the tap resolves and the walk leaves the chat intact — +# not the scroll distance. +- tapOn: { id: bubble_reply_quote } +- assertVisible: "${output.body}" +- assertVisible: { id: chat_screen } From 92b42125473ad73c1e9516c55a0ca58ce9a85758 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 00:17:10 -0400 Subject: [PATCH 16/18] fix(chat): preview a reply as the body its sender typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conversation whose newest message is a reply showed no preview at all — the row rendered the timestamp and nothing else. `formatPreview` mapped `MessageContent.Reply` to null, so the wrapper swallowed the text inside it. Unwrap the reply and preview its content as that content would have previewed on its own. The "You:" prefix comes from the inner content, so it lands once rather than once per layer. The unwrap is bounded at four levels: nothing the app sends nests a reply inside a reply, but this content arrives off the wire. The `-> null` predates the reply feature (#1132); sending a reply is what made it reachable. --- .../shared/chat/ui/ChatSummaryMapping.kt | 103 ++++++++++------- .../shared/chat/ui/ChatSummaryPreviewTest.kt | 108 ++++++++++++++++++ 2 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt 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 f1b96b66d..89dfd6067 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 @@ -47,48 +47,71 @@ private fun ChatSummary.formatPreview( ): String? { val lastMsg = metadata.lastMessage ?: return null val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId - return lastMsg.content.firstOrNull()?.let { content -> - when (content) { - is MessageContent.Text -> { - val message = content.text.takeIf { it.isNotEmpty() } ?: return null - if (sentBySelf) { - resources.getString(R.string.label_chat_preview_sentMessage, message) - } else { - message - } - } - is MessageContent.Cash -> { - val formatted = content.amount.formatted() - // The reserve is branded "Dollars", so naming it reads as "$1.00 of Dollars" — - // the amount alone already says it. Every other token still gets named. - val name = if (content.mint == Mint.usdf) { - "" - } else { - content.tokenName.ifBlank { tokensByMint[content.mint]?.name.orEmpty() } - } - val label = if (name.isNotBlank()) { - resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) - } else { - formatted - } - val previewRes = when (content.action) { - MessageContent.Cash.Action.TIPPED -> - if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash - MessageContent.Cash.Action.SENT -> - if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash - } - resources.getString(previewRes, label) - } + return lastMsg.content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources) +} - // The feed carries the newest message that still has content, so a tombstone only - // reaches here when every message in the chat is deleted — and then there is nothing - // to preview. - is MessageContent.Deleted -> null +/** + * The row's line for one piece of message content, or null when there is nothing worth previewing. + * + * [depth] bounds the reply unwrap below. Nothing the app sends nests a reply inside a reply, but + * this content arrives off the wire, so a malformed chain must not recurse forever. + */ +private fun MessageContent.previewText( + sentBySelf: Boolean, + tokensByMint: Map, + resources: ResourceHelper, + depth: Int = 0, +): String? = when (this) { + is MessageContent.Text -> { + val message = text.takeIf { it.isNotEmpty() } + when { + message == null -> null + sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) + else -> message + } + } - // TODO: - is MessageContent.Media -> null - is MessageContent.Reply -> null - is MessageContent.System -> null + is MessageContent.Cash -> { + val formatted = amount.formatted() + // The reserve is branded "Dollars", so naming it reads as "$1.00 of Dollars" — + // the amount alone already says it. Every other token still gets named. + val name = if (mint == Mint.usdf) { + "" + } else { + tokenName.ifBlank { tokensByMint[mint]?.name.orEmpty() } } + val label = if (name.isNotBlank()) { + resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) + } else { + formatted + } + val previewRes = when (action) { + MessageContent.Cash.Action.TIPPED -> + if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash + MessageContent.Cash.Action.SENT -> + if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash + } + resources.getString(previewRes, label) + } + + // A reply wraps what the sender actually typed, so it previews as that content would have + // without the citation. The row has no room to say what was cited, and the reply's own body is + // the part that changed. The "You:" prefix comes from the inner content, so it lands once + // rather than once per layer. + is MessageContent.Reply -> if (depth >= MAX_REPLY_UNWRAP_DEPTH) { + null + } else { + content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources, depth + 1) } + + // The feed carries the newest message that still has content, so a tombstone only + // reaches here when every message in the chat is deleted — and then there is nothing + // to preview. + is MessageContent.Deleted -> null + + // TODO: + is MessageContent.Media -> null + is MessageContent.System -> null } + +private const val MAX_REPLY_UNWRAP_DEPTH = 4 diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt new file mode 100644 index 000000000..28d6760a2 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -0,0 +1,108 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.core.R +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.ChatSummary +import com.getcode.opencode.model.core.ID +import com.getcode.util.resources.ResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * The conversation row previews the last message. A reply is a wrapper around the body the sender + * typed, so the row has to look through it — otherwise a chat whose newest message is a reply + * shows no preview at all. + */ +class ChatSummaryPreviewTest { + + private val sentAt = Instant.fromEpochSeconds(1_000) + private val self: ID = listOf(1) + private val other: ID = listOf(2) + + private val resources = mockk() + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + private fun summary( + content: List, + senderId: ID?, + ) = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(byteArrayOf(9)), + type = ChatType.TIP_DM, + members = listOf( + ChatMember(userId = self, userProfile = profile("Me"), pointers = emptyList()), + ChatMember(userId = other, userProfile = profile("Them"), pointers = emptyList()), + ), + lastMessage = ChatMessage( + messageId = 42, + senderId = senderId, + content = content, + timestamp = sentAt, + unreadSeq = 0, + ), + lastActivity = sentAt, + ), + unreadCount = 0, + ) + + private fun preview(content: List, senderId: ID? = other): String? = + summary(content, senderId) + .toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources) + .lastMessagePreview + + @Test + fun `a reply previews as the body the sender typed`() { + val reply = MessageContent.Reply( + repliedMessageId = 7, + content = listOf(MessageContent.Text("on my way")), + ) + + assertEquals("on my way", preview(listOf(reply))) + } + + @Test + fun `a reply the user sent is prefixed once, not once per layer`() { + every { + resources.getString(R.string.label_chat_preview_sentMessage, "on my way") + } returns "You: on my way" + + val reply = MessageContent.Reply( + repliedMessageId = 7, + content = listOf(MessageContent.Text("on my way")), + ) + + assertEquals("You: on my way", preview(listOf(reply), senderId = self)) + } + + @Test + fun `a reply carrying no body has nothing to preview`() { + val reply = MessageContent.Reply(repliedMessageId = 7, content = emptyList()) + + assertNull(preview(listOf(reply))) + } + + @Test + fun `a reply nested past the unwrap bound gives up rather than looping`() { + // Nothing the app sends looks like this; it stands in for malformed content off the wire. + var nested: MessageContent = MessageContent.Text("buried") + repeat(6) { nested = MessageContent.Reply(repliedMessageId = 7, content = listOf(nested)) } + + assertNull(preview(listOf(nested))) + } +} From 869eab61bfa16932d410a1b95294493a53d3725e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 08:58:08 -0400 Subject: [PATCH 17/18] fix(messenger): match the iOS reply surface, affordance, and motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply mode was three ports short of iOS. The composer reused ChatQuotePanel — the card that goes inside a sent bubble — inset by the composer's own horizontal padding, where iOS draws a bare strip whose 4pt rule sits flush to the screen's leading edge. The swipe had no icon, a regression against the legacy MessageNode affordance it replaced. And the strip appeared and vanished on a single frame with no animation at all. ComposerReplyStrip is the composer's own citation now, with ChatQuotePanel left to the bubble. Sharing one composable meant either padding one shape into the other or carrying a mode flag, so they are separate and each keeps its styling in its own defaults. The strip needs two stops of the sender's palette rather than one, because iOS draws the rule in .start and the name in .middle; both come off the one generateComplementaryColorPalette call, so they cannot disagree about whose colour they are. The affordance forced the swipe distances to move with it. Its reveal is defined as offset / triggerThreshold, and Android's threshold was 40% of screen width — about 164dp on a Pixel 10 — against iOS's flat 48pt, so the icon could not read correctly at any point of the drag without porting iOS's absolute 64/48/20/32. The circle is parked at a static offset rather than an animated one: the row it sits in already carries the drag translation, so the two move together. Entry and exit run on ChatMotion.replySurface, spring(0.22, bounce 0). The zero bounce is deliberate — the transcript's bottom inset tracks the bar's height every frame, so an overshoot would drag every message past where it settles. Entry does not fade, matching iOS: the clip edge uncovering the quote is the whole effect. Exit does, so the quote dissolves rather than being sliced off by an edge moving over fully opaque text. The quote is held one target past the state so the collapse animates the citation rather than an empty strip. iOS's bar-surface re-tint while replying is not ported. It swaps a flat fill between backgroundMain and backgroundSecondary; Android's bar is a haze blur over a gradient to transparent, and a flat secondary fill would stop the transcript showing through it. --- .../app/messenger/internal/ChatViewModel.kt | 4 +- .../internal/screens/ChatAnimations.kt | 22 +++ .../screens/components/ChatBottomBar.kt | 52 ++--- .../internal/screens/components/MessageRow.kt | 77 +++++++- .../screens/components/SwipeToReply.kt | 94 ++++++--- .../internal/ChatMessageActionReducerTest.kt | 1 + .../flipcash/shared/chat/models/ChatQuote.kt | 11 +- .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 11 +- .../shared/chat/ui/ComposerReplyStrip.kt | 178 ++++++++++++++++++ 9 files changed, 383 insertions(+), 67 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 011cb62d6..034271821 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 @@ -403,6 +403,7 @@ internal class ChatViewModel @Inject constructor( */ private suspend fun ChatMessage.toQuote(): ChatQuote { val body = content.firstOrNull() + val palette = senderId?.let { generateComplementaryColorPalette(it) } return ChatQuote( messageId = messageId, authorName = if (isFromSelf) { @@ -429,7 +430,8 @@ internal class ChatViewModel @Inject constructor( else -> ChatQuoteSnippet.Text("") }, - accent = senderId?.let { generateComplementaryColorPalette(it)?.first }, + accent = palette?.first, + nameAccent = palette?.second, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt index 5cb208fbe..be635b43d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt @@ -1,11 +1,14 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Spring import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.ui.Alignment import androidx.compose.ui.unit.IntSize // All chat animation spring specs in one place. @@ -31,6 +34,25 @@ internal object ChatAnimations { // Matches the scale UIKit's context menu gives its preview on iOS. val lift: SpringSpec = spring(dampingRatio = 0.68f, stiffness = 600f) + // Reply mode entry/exit — the bar grows a strip on top of itself and shrinks back. + // Matches iOS replySurface: .spring(duration: 0.22, bounce: 0). + // + // No bounce, and that is the point: the transcript's bottom inset tracks the bar's height every + // frame, so an overshoot here drags every message past where it settles and back. + val replySurface: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f) + private val replySurfaceIntSize: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f) + + // Asymmetric, as on iOS: nothing fades in, because the clip edge uncovering the quote is the + // whole effect, and a fade on top of it reads as a second animation. Going away it does fade, + // so the quote dissolves rather than being sliced off by an edge moving over text that is still + // fully opaque. + val replySurfaceEnter: EnterTransition = + expandVertically(replySurfaceIntSize, expandFrom = Alignment.Top) + val replySurfaceExit: ExitTransition = + shrinkVertically(replySurfaceIntSize, shrinkTowards = Alignment.Top) + fadeOut(replySurface) + // Receipt label exit when a new message is sent — fade out + collapse. private val deliveredIntSize: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 4f96fcf95..fb30fb08a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -48,8 +49,7 @@ import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme -import com.flipcash.shared.chat.ui.ChatQuotePanel -import com.getcode.ui.components.CircularIconButton +import com.flipcash.shared.chat.ui.ComposerReplyStrip import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit import com.getcode.ui.components.chat.TypingIndicator @@ -126,7 +126,8 @@ internal fun UserControlBottomBar( AnimatedContent( modifier = Modifier .measured { buttonHeight = it.height } - .padding(horizontal = CodeTheme.dimens.inset) + // The horizontal inset sits on the composer row rather than here, because the + // reply strip is a sibling of that row and has to reach the screen's edge. .padding(vertical = CodeTheme.dimens.grid.x3) .navigationBarsPadding() // typingConstraints.enabled starts false and only resolves a frame or two after @@ -166,36 +167,37 @@ internal fun UserControlBottomBar( // the composer's text, while a reply's subject is a different message that is very // likely scrolled off screen. Gated on canType so a reply strip never sits above a // bar with nothing to send from. - state.replyingTo?.takeIf { canType }?.let { quote -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.grid.x2), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.CenterVertically, - ) { - ChatQuotePanel( + // + // The bar grows into the strip rather than the strip appearing over the bar, so + // the reveal is a height animation with the content clipped by the edge that is + // moving. `replyingTo` is held past the dismissal by AnimatedVisibility's own + // retention, so the quote is still there to fade out on the way down. + // + // Held one target past the state: cancelling clears `replyingTo` on the frame + // the collapse starts, and reading it directly would shrink an empty strip. It + // still follows a live change, so replying to a second message while the strip + // is up swaps the quote rather than keeping the first. + var lastQuote by remember { mutableStateOf(state.replyingTo) } + state.replyingTo?.let { lastQuote = it } + AnimatedVisibility( + visible = state.replyingTo != null && canType, + enter = ChatAnimations.replySurfaceEnter, + exit = ChatAnimations.replySurfaceExit, + ) { + lastQuote?.let { quote -> + ComposerReplyStrip( quote = quote, + onDismiss = { dispatch(ChatViewModel.Event.CancelReply) }, modifier = Modifier - .weight(1f) + .padding(bottom = CodeTheme.dimens.grid.x2) .testTag("composer_reply_strip"), ) - CircularIconButton( - onClick = { dispatch(ChatViewModel.Event.CancelReply) }, - testTag = "action_cancel_reply", - ) { size -> - Icon( - modifier = Modifier.requiredSize(size), - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.action_cancelReply), - tint = Color.White, - ) - } } } Row( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.Bottom, ) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 72b4b5620..bdeb8aac6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -10,8 +10,15 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,6 +29,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp @@ -120,6 +129,13 @@ internal fun MessageRow( label = "messageLift", ) + val swipe = rememberSwipeToReply( + enabled = bubble != null && + !selecting && + MessageCapability.Reply in bubble.capabilities, + onReply = { bubble?.let { onAction(ChatAction.ReplyTo(it)) } }, + ) + Box( modifier = Modifier .padding(bottom = bottomSpacing) @@ -161,14 +177,7 @@ internal fun MessageRow( } // No swipe with the backdrop up either, and for the same reason: the bar is already // acting on a message. - .then( - rememberSwipeToReply( - enabled = bubble != null && - !selecting && - MessageCapability.Reply in bubble.capabilities, - onReply = { bubble?.let { onAction(ChatAction.ReplyTo(it)) } }, - ) - ), + .then(swipe.modifier), ) { when (item) { is ChatListItem.DateSeparator -> Box(insertionModifier) { @@ -226,9 +235,61 @@ internal fun MessageRow( } } } + + SwipeToReplyAffordance( + progress = swipe::progress, + modifier = Modifier.align(Alignment.CenterStart), + ) } } +/** + * The mark the swipe uncovers: a circle in the gutter the row is opening, growing and fading in as + * the drag approaches the distance that fires the reply. + * + * Parked at a fixed offset rather than an animated one. The row it sits in is already translated by + * the drag, so the two move together, and the constant is iOS's per-frame centre + * (`affordanceInset + radius - maxTranslation`) restated as a leading edge, which drops the radius: + * the circle lands 20dp from the row's leading edge at full travel, and off that edge — clipped by + * the list, and transparent besides — at rest. + */ +@Composable +private fun SwipeToReplyAffordance( + progress: () -> Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .offset(x = AFFORDANCE_INSET - SWIPE_MAX_TRANSLATION) + .size(AFFORDANCE_SIZE) + .graphicsLayer { + val fraction = progress() + alpha = fraction + // Never from nothing: the circle is already most of its size when it starts to + // show, so it reads as arriving rather than as inflating. + scaleX = 0.6f + 0.4f * fraction + scaleY = 0.6f + 0.4f * fraction + } + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(AFFORDANCE_SIZE - AFFORDANCE_ICON_INSET * 2), + imageVector = Icons.AutoMirrored.Filled.Reply, + // Decorative: the gesture it marks is already reachable from the selection bar, which + // is what a screen reader drives the reply from. + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + ) + } +} + +private val AFFORDANCE_SIZE = 32.dp +private val AFFORDANCE_INSET = 20.dp +private val AFFORDANCE_ICON_INSET = 8.dp +private val SWIPE_MAX_TRANSLATION = 64.dp + @Composable private fun bottomSpacingFor( index: Int, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt index 65ef53b0e..bc715cece 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.offset -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -27,31 +26,31 @@ internal enum class ReplyDragAnchor { Rest, Reply } /** * A trailing-ward drag on a message row that dispatches a reply. * - * Ported from the legacy `MessageNode`, including its numbers. The row never settles open — - * `confirmValueChange` always refuses — so the gesture is a pull that springs back: the haptic fires - * the moment the threshold is crossed, and the action fires as the row returns, which is what makes - * an abandoned drag cost nothing. + * The row never settles open — `confirmValueChange` always refuses — so the gesture is a pull that + * springs back: the haptic fires the moment the threshold is crossed, and the action fires as the + * row returns, which is what makes an abandoned drag cost nothing. + * + * The distances are iOS's, in absolute units rather than the fractions of screen width this was + * ported with. They have to be: [progress] is the fraction of the trigger distance travelled, and it + * is what draws the affordance, so a threshold that moves with the screen would put the icon at a + * different point of its reveal on every device — and on a 411dp screen the old 0.40 fraction put + * the trigger at roughly three times iOS's. */ @Composable internal fun rememberSwipeToReply( enabled: Boolean, onReply: () -> Unit, -): Modifier { - if (!enabled) return Modifier - +): SwipeToReplyState { val density = LocalDensity.current val vibrator = LocalVibrator.current - // The screen width, not the row's own. A row spans the transcript's full width, and reading it - // from BoxWithConstraints would mean building this modifier inside the constraints scope — - // which is not where the row's outer Box applies its modifiers. - val widthPx = with(density) { LocalConfiguration.current.screenWidthDp.dp.toPx() } - val threshold = widthPx * SWIPE_THRESHOLD_FRACTION + val maxPx = with(density) { MAX_TRANSLATION.toPx() } + val triggerPx = with(density) { TRIGGER_THRESHOLD.toPx() } var crossed by remember { mutableStateOf(false) } - val anchors = remember(threshold) { + val anchors = remember(maxPx) { DraggableAnchors { ReplyDragAnchor.Rest at 0f - ReplyDragAnchor.Reply at threshold + ReplyDragAnchor.Reply at maxPx } } @@ -63,7 +62,9 @@ internal fun rememberSwipeToReply( AnchoredDraggableState( initialValue = ReplyDragAnchor.Rest, anchors = anchors, - positionalThreshold = { it * 0.9f }, + // A fraction of the distance between the anchors, so the trigger lands at + // TRIGGER_THRESHOLD rather than at the full travel. + positionalThreshold = { it * (triggerPx / maxPx) }, velocityThreshold = { Float.POSITIVE_INFINITY }, confirmValueChange = { target -> if (target == ReplyDragAnchor.Reply && !crossed) { @@ -90,18 +91,55 @@ internal fun rememberSwipeToReply( } } - return Modifier - .anchoredDraggable( - state = dragState, - orientation = Orientation.Horizontal, + // Nothing to drag and nothing to draw, but the hooks above still have to be called in the same + // order on every composition, so the disabled case is decided here rather than at the top. + if (!enabled) return SwipeToReplyState.Disabled + + return remember(dragState, maxPx, triggerPx) { + SwipeToReplyState( + modifier = Modifier + .anchoredDraggable( + state = dragState, + orientation = Orientation.Horizontal, + ) + .offset { + IntOffset(x = dragState.clampedOffset(maxPx).roundToInt(), y = 0) + }, + // Read through a lambda, not captured: this is sampled inside a graphicsLayer block, so + // the affordance redraws on every frame of the drag without recomposing the row. + offsetPx = { dragState.clampedOffset(maxPx) }, + triggerPx = triggerPx, ) - .offset { - IntOffset( - x = dragState.offset.coerceIn(0f, widthPx * MAX_OFFSET_FRACTION).roundToInt(), - y = 0, - ) - } + } } -private const val SWIPE_THRESHOLD_FRACTION = 0.40f -private const val MAX_OFFSET_FRACTION = 0.30f +/** + * What a row needs from the gesture: the modifier that carries it, and how far it has travelled, so + * the row can draw the affordance the drag is uncovering. + */ +internal class SwipeToReplyState( + val modifier: Modifier, + private val offsetPx: () -> Float, + private val triggerPx: Float, +) { + /** How far the drag has come as a fraction of the distance that fires the reply, capped at 1. */ + fun progress(): Float = (offsetPx() / triggerPx).coerceIn(0f, 1f) + + companion object { + val Disabled = SwipeToReplyState(Modifier, { 0f }, 1f) + } +} + +/** + * The drag's travel, capped at the full translation and never NaN — `offset` has no value until the + * anchors have been applied, and a NaN reaching `IntOffset` throws. + */ +@Suppress("DEPRECATION") +private fun AnchoredDraggableState.clampedOffset(maxPx: Float): Float = + offset.takeIf { !it.isNaN() }?.coerceIn(0f, maxPx) ?: 0f + +/** iOS's `maxTranslation`: how far the row itself can move. */ +private val MAX_TRANSLATION = 64.dp + +/** iOS's `triggerThreshold`: the travel that arms the reply and fires the haptic. */ +private val TRIGGER_THRESHOLD = 48.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index 30922cc2d..36cb1294b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -229,6 +229,7 @@ class ChatMessageActionReducerTest { authorName = "Ada", snippet = ChatQuoteSnippet.Text("the original"), accent = null, + nameAccent = null, ) @Test diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt index c9a9b1cda..66e523b8e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatQuote.kt @@ -17,7 +17,8 @@ data class ChatQuote( val authorName: String, val snippet: ChatQuoteSnippet, /** - * The cited sender's colour, or `null` when the message carries no sender id. + * The cited sender's colour, used for the rule down the citation's leading edge, or `null` when + * the message carries no sender id. * * Nullable because the palette is: `generateComplementaryColorPalette` returns `null` for a * message with no id, and the fallback for that case is a theme colour only a composable can @@ -25,6 +26,14 @@ data class ChatQuote( * per frame. */ val accent: Color?, + /** + * The next stop of the same sender's palette, used for their name in the composer strip. + * + * Two stops rather than one because iOS draws the rule and the name in different colours, and + * the name sits on the bar's own ground where the rule's darker stop reads as muddy. Both come + * from the one derivation, so they cannot disagree about whose colour this is. + */ + val nameAccent: Color?, ) /** What a quote shows of the message it cites. */ diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index 203809cdf..effa29ec9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -27,11 +27,14 @@ import com.getcode.ui.components.PriceWithFlag import com.getcode.ui.core.addIf /** - * A citation of another message: an accent bar, the author's name, and a snippet of what they said. + * A citation of another message inside a sent bubble: an accent bar, the author's name, and a + * snippet of what they said. * - * The same composable serves the composer strip and the panel inside a sent bubble, so the two can - * never drift. Every styling decision lives here and in [QuotePanelDefaults] — the iOS reply UI is - * still pending design review, and this keeps that review a one-file change. + * The composer's citation is [ComposerReplyStrip], not this. They looked like one component and + * were one until the composer half was matched to iOS, which draws a bare strip flush to the + * screen's edge rather than a card on a tinted ground. Sharing a composable across the two meant + * either padding one of them into the other's shape or carrying a mode flag, so they are separate + * and each keeps its styling in its own defaults object. */ @Composable fun ChatQuotePanel( diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt new file mode 100644 index 000000000..480d67560 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt @@ -0,0 +1,178 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.core.R +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.getcode.opencode.compose.LocalExchange +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.PriceWithFlag + +/** + * The quoted original above the composer while a reply is being written: a rule in the author's own + * colour flush against the screen's leading edge, their name over one or two lines of what they + * said, and the way out on the trailing edge. + * + * Deliberately not [ChatQuotePanel]. There is no card and no tinted ground here, because the strip + * is not a thing sitting on the bar — it *is* the top of the bar, and the rule running the full + * height of the region the bar grew by is what makes the reveal read as the bar getting taller + * rather than as a panel arriving. The panel inside a sent bubble has the opposite problem: it sits + * on a filled bubble and needs an edge to separate it, so the two surfaces stay separate composables. + * + * Matches iOS's `ComposerReplyStrip` measurement for measurement, including the rule at x = 0 with + * no leading inset of any kind. + */ +@Composable +fun ComposerReplyStrip( + quote: ChatQuote, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val rule = quote.accent ?: CodeTheme.colors.tertiary + val name = quote.nameAccent ?: rule + + Row( + // Intrinsic minimum, so the rule can fill a height the text column decides. Without it a + // fillMaxHeight child in an unbounded Row measures to zero. + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(end = ComposerReplyStripDefaults.trailingPadding), + horizontalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.gap), + verticalAlignment = Alignment.CenterVertically, + ) { + // Square, unclipped, and with no leading padding on the row: it starts at the screen edge. + Box( + modifier = Modifier + .width(ComposerReplyStripDefaults.ruleWidth) + .fillMaxHeight() + .background(rule), + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = ComposerReplyStripDefaults.textPadding), + verticalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.nameGap), + ) { + Text( + text = quote.authorName, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + QuoteLine(quote.snippet) + } + + // A large, thin ✕ rather than a small bold one, sized to sit against two lines of quote + // without crowding them, in a hit target wider than the glyph. + Box( + modifier = Modifier + .size(ComposerReplyStripDefaults.dismissTarget) + .clickable(onClick = onDismiss) + .testTag("action_cancel_reply"), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.requiredSize(ComposerReplyStripDefaults.dismissGlyph), + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.action_cancelReply), + tint = CodeTheme.colors.textSecondary, + ) + } + } +} + +/** + * The quoted original itself, one step under the bubble body's size and in the same weight: the + * quote is the subject of the strip, so it is read rather than glanced at. Primary text for the same + * reason — dimming it makes it look like placeholder text for the field below. + */ +@Composable +private fun QuoteLine(snippet: ChatQuoteSnippet) { + val style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium) + when (snippet) { + is ChatQuoteSnippet.Text -> Text( + text = snippet.body, + style = style, + color = CodeTheme.colors.textMain, + maxLines = ComposerReplyStripDefaults.textMaxLines, + overflow = TextOverflow.Ellipsis, + ) + + // A payment carries the flag and the mint's name the cash card leads with. The amount alone + // reads as a number; with the flag beside it, it reads as the payment being answered. + is ChatQuoteSnippet.Cash -> { + val exchange = LocalExchange.current + val currencyCode = snippet.amount.currencyCode.name + Row( + horizontalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.cashGap), + verticalAlignment = Alignment.CenterVertically, + ) { + PriceWithFlag( + amount = snippet.amount.formatted(), + currencyCode = currencyCode, + iconSize = ComposerReplyStripDefaults.flagSize, + flag = exchange.getFlagByCurrency(currencyCode), + text = { formatted -> + Text( + text = formatted, + style = style, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) + }, + ) + Text( + text = snippet.tokenName, + style = style, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** Every measurement the strip makes, carried over from iOS. */ +private object ComposerReplyStripDefaults { + val ruleWidth = 4.dp + val gap = 9.dp + val nameGap = 2.dp + val cashGap = 6.dp + val textPadding = 8.dp + val trailingPadding = 8.dp + + /** Sized to the cap height of the amount beside it, so the flag reads as a mark on the line. */ + val flagSize = 16.dp + val dismissTarget = 34.dp + val dismissGlyph = 20.dp + const val textMaxLines = 2 +} From 166a6c73027cb3ee50de9d5ca553970c8ea30e2d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 09:16:46 -0400 Subject: [PATCH 18/18] fix(chat): ground both reply quotes the way iOS does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer strip was built from a stale copy of iOS's ComposerReplyStrip — a bare strip flush to the screen edge with a hairline close glyph. The shipped design is a rounded card inset to the composer's own margins and grounded in glass, with a filled disc for the dismiss: the glass samples the transcript, so a hairline glyph's contrast changed with whatever message scrolled past. The in-bubble panel had drifted the other way, to a neutral White10 scrim, a fixed 32dp rule, and the author's name in the palette's start stop. iOS tints the ground with the author's colour at 14%, runs the rule the full height, and takes the name from the middle stop, which ChatQuote already carries. - ComposerReplyStrip: 14dp card on the same liquid glass as the nav pill, 6dp rule clipped by the corners, 50dp floor, 22dp disc in a 34dp target. - ChatQuotePanel: tinted ground, full-height 3dp rule, name from nameAccent, and iOS's 6/8/8/1/5dp insets and 0.55/0.75/0.35 text alphas. - chat-ui takes haze as api, since HazeState is now in the strip's signature. --- .../screens/components/ChatBottomBar.kt | 6 +- apps/flipcash/shared/chat-ui/build.gradle.kts | 2 + .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 72 ++++++++----- .../shared/chat/ui/ComposerReplyStrip.kt | 101 +++++++++++++----- 4 files changed, 130 insertions(+), 51 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index fb30fb08a..e8e4fa072 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -126,8 +126,6 @@ internal fun UserControlBottomBar( AnimatedContent( modifier = Modifier .measured { buttonHeight = it.height } - // The horizontal inset sits on the composer row rather than here, because the - // reply strip is a sibling of that row and has to reach the screen's edge. .padding(vertical = CodeTheme.dimens.grid.x3) .navigationBarsPadding() // typingConstraints.enabled starts false and only resolves a frame or two after @@ -188,7 +186,11 @@ internal fun UserControlBottomBar( ComposerReplyStrip( quote = quote, onDismiss = { dispatch(ChatViewModel.Event.CancelReply) }, + hazeState = hazeState, modifier = Modifier + // Inset to the composer row's own margins, so the card's edges + // line up with the field it sits above. + .padding(horizontal = CodeTheme.dimens.inset) .padding(bottom = CodeTheme.dimens.grid.x2) .testTag("composer_reply_strip"), ) diff --git a/apps/flipcash/shared/chat-ui/build.gradle.kts b/apps/flipcash/shared/chat-ui/build.gradle.kts index 21f7b8da4..1a126ccb1 100644 --- a/apps/flipcash/shared/chat-ui/build.gradle.kts +++ b/apps/flipcash/shared/chat-ui/build.gradle.kts @@ -18,6 +18,8 @@ dependencies { implementation(project(":services:flipcash")) implementation(project(":services:opencode-compose")) implementation(project(":libs:datetime")) + // api: ComposerReplyStrip takes the host's HazeState so its card can sample the transcript. + api(libs.bundles.haze) implementation(libs.androidx.paging.runtime) implementation(libs.compose.paging) api(project(":apps:flipcash:shared:common-ui")) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index effa29ec9..032c697f2 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -15,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -22,19 +25,18 @@ import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.getcode.opencode.compose.LocalExchange import com.getcode.theme.CodeTheme -import com.getcode.theme.White10 import com.getcode.ui.components.PriceWithFlag import com.getcode.ui.core.addIf /** - * A citation of another message inside a sent bubble: an accent bar, the author's name, and a - * snippet of what they said. + * A citation of another message inside a sent bubble: a rule in the author's colour, their name, and + * a snippet of what they said, on a ground tinted by that same colour. * - * The composer's citation is [ComposerReplyStrip], not this. They looked like one component and - * were one until the composer half was matched to iOS, which draws a bare strip flush to the - * screen's edge rather than a card on a tinted ground. Sharing a composable across the two meant - * either padding one of them into the other's shape or carrying a mode flag, so they are separate - * and each keeps its styling in its own defaults object. + * The composer's citation is [ComposerReplyStrip], not this. They look alike and were one component + * until the composer half was matched to iOS, which grounds it in glass sampling the transcript + * while this one is tinted — a panel inside a filled bubble cannot blur what is behind it, because + * what is behind it is the bubble. Sharing a composable meant either carrying a mode flag or padding + * one into the other's shape, so they are separate and each keeps its styling in its own defaults. */ @Composable fun ChatQuotePanel( @@ -43,31 +45,42 @@ fun ChatQuotePanel( onClick: (() -> Unit)? = null, ) { val accent = quote.accent ?: CodeTheme.colors.tertiary + val name = quote.nameAccent ?: accent Row( + // Intrinsic minimum, so the rule can fill a height the text column decides. Without it a + // fillMaxHeight child in an unbounded Row measures to zero. modifier = modifier .clip(QuotePanelDefaults.shape) - // A white scrim rather than the theme's own ground: the panel sits inside a bubble - // that is already drawn on that ground, so it would otherwise be invisible. - .background(White10) + // The author's own colour at low alpha rather than a neutral scrim: the panel sits on a + // filled bubble, and tinting it to match the rule is what separates the two surfaces. + .background(accent.copy(alpha = QuotePanelDefaults.groundAlpha)) .addIf(onClick != null) { Modifier.clickable { onClick?.invoke() } } - .padding(QuotePanelDefaults.padding), + .height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(QuotePanelDefaults.gap), verticalAlignment = Alignment.CenterVertically, ) { + // Square and flush to the panel's leading edge; the panel's own clip is what rounds it. Box( modifier = Modifier .width(QuotePanelDefaults.accentWidth) - .height(QuotePanelDefaults.accentHeight) - .clip(RoundedCornerShape(QuotePanelDefaults.accentWidth / 2)) + .fillMaxHeight() .background(accent), ) - Column(verticalArrangement = Arrangement.spacedBy(QuotePanelDefaults.nameGap)) { + Column( + modifier = Modifier + .padding( + end = QuotePanelDefaults.trailingPadding, + top = QuotePanelDefaults.verticalPadding, + bottom = QuotePanelDefaults.verticalPadding, + ), + verticalArrangement = Arrangement.spacedBy(QuotePanelDefaults.nameGap), + ) { Text( text = quote.authorName, - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), - color = accent, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = name, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -76,7 +89,9 @@ fun ChatQuotePanel( is ChatQuoteSnippet.Text -> Text( text = snippet.body, style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, + // White alphas rather than the theme's secondary, which is a blue-grey: the + // ground under this text is the author's colour, and a second hue muddies it. + color = Color.White.copy(alpha = QuotePanelDefaults.snippetAlpha), maxLines = QuotePanelDefaults.snippetMaxLines, overflow = TextOverflow.Ellipsis, ) @@ -86,7 +101,7 @@ fun ChatQuotePanel( val exchange = LocalExchange.current val currencyCode = snippet.amount.currencyCode.name Row( - horizontalArrangement = Arrangement.spacedBy(QuotePanelDefaults.nameGap), + horizontalArrangement = Arrangement.spacedBy(QuotePanelDefaults.cashGap), verticalAlignment = Alignment.CenterVertically, ) { PriceWithFlag( @@ -98,7 +113,9 @@ fun ChatQuotePanel( Text( text = formatted, style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, + color = Color.White.copy( + alpha = QuotePanelDefaults.amountAlpha, + ), maxLines = 1, ) }, @@ -106,7 +123,7 @@ fun ChatQuotePanel( Text( text = snippet.tokenName, style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, + color = Color.White.copy(alpha = QuotePanelDefaults.tokenAlpha), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -117,14 +134,19 @@ fun ChatQuotePanel( } } -/** Every visual decision the panel makes, in one place for the pending design review. */ +/** Every measurement the panel makes, carried over from iOS. */ private object QuotePanelDefaults { val shape = RoundedCornerShape(8.dp) - val padding = 8.dp val gap = 8.dp - val nameGap = 2.dp + val trailingPadding = 8.dp + val verticalPadding = 6.dp + val nameGap = 1.dp + val cashGap = 5.dp val accentWidth = 3.dp - val accentHeight = 32.dp val flagSize = 14.dp + const val groundAlpha = 0.14f + const val snippetAlpha = 0.55f + const val amountAlpha = 0.75f + const val tokenAlpha = 0.35f const val snippetMaxLines = 2 } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt index 480d67560..1c1601a06 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,17 +11,23 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.filled.Close 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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -32,41 +39,73 @@ import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.getcode.opencode.compose.LocalExchange import com.getcode.theme.CodeTheme import com.getcode.ui.components.PriceWithFlag +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur /** - * The quoted original above the composer while a reply is being written: a rule in the author's own - * colour flush against the screen's leading edge, their name over one or two lines of what they - * said, and the way out on the trailing edge. + * The quoted original above the composer while a reply is being written: a card carrying a rule in + * the author's own colour, their name over one or two lines of what they said, and the way out on + * the trailing edge. * - * Deliberately not [ChatQuotePanel]. There is no card and no tinted ground here, because the strip - * is not a thing sitting on the bar — it *is* the top of the bar, and the rule running the full - * height of the region the bar grew by is what makes the reveal read as the bar getting taller - * rather than as a panel arriving. The panel inside a sent bubble has the opposite problem: it sits - * on a filled bubble and needs an edge to separate it, so the two surfaces stay separate composables. + * The card is glass rather than a flat fill, matching iOS, so it reads as a surface floating over + * the transcript rather than as part of the bar. That is also why the dismiss control is a filled + * disc: the ground behind it samples whatever message is scrolled underneath, and a hairline glyph + * on its own changed contrast as the transcript moved. * - * Matches iOS's `ComposerReplyStrip` measurement for measurement, including the rule at x = 0 with - * no leading inset of any kind. + * Deliberately not [ChatQuotePanel], which sits inside a filled bubble and is tinted by the author's + * colour rather than blurred. */ @Composable fun ComposerReplyStrip( quote: ChatQuote, onDismiss: () -> Unit, modifier: Modifier = Modifier, + hazeState: HazeState? = null, ) { val rule = quote.accent ?: CodeTheme.colors.tertiary val name = quote.nameAccent ?: rule + val shape = RoundedCornerShape(ComposerReplyStripDefaults.cornerRadius) + // Same liquid glass as the nav pill and the app bar's circular buttons: a wide blur plus a tint + // toward a grey lifted off the (near-black) background, so the card reads as light frosted glass + // above dark content rather than as the background tone. `clip` must precede `hazeBlur` to bound + // the blur to the rounded rect. Falls back to a near-opaque fill of the same tint when the host + // has no HazeState to sample. + val backdrop = CodeTheme.colors.background + val glassBlurRadius = CodeTheme.dimens.grid.x4 + val glassTint = lerp(backdrop, Color.White, 0.18f) + // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. + val liquidGlass = HazeBlurStyle { + blurRadius(glassBlurRadius) + backgroundColor(backdrop) + colorEffects(listOf(HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)))) + } + val ground = if (hazeState != null) { + Modifier.hazeBlur(HazeInput.Sources(hazeState), liquidGlass) + } else { + Modifier.background(glassTint.copy(alpha = 0.9f)) + } + Row( - // Intrinsic minimum, so the rule can fill a height the text column decides. Without it a - // fillMaxHeight child in an unbounded Row measures to zero. + // heightIn before the intrinsic pass: `height(IntrinsicSize.Min)` enforces the incoming + // constraints, so the floor survives it. Intrinsic minimum, in turn, is what lets the rule + // fill a height the text column decides — a fillMaxHeight child in an unbounded Row measures + // to zero. modifier = modifier .fillMaxWidth() + .clip(shape) + .then(ground) + .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) + .heightIn(min = ComposerReplyStripDefaults.minHeight) .height(IntrinsicSize.Min) .padding(end = ComposerReplyStripDefaults.trailingPadding), horizontalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.gap), verticalAlignment = Alignment.CenterVertically, ) { - // Square, unclipped, and with no leading padding on the row: it starts at the screen edge. + // Square and flush to the card's leading edge; the card's own clip is what rounds it. Box( modifier = Modifier .width(ComposerReplyStripDefaults.ruleWidth) @@ -90,8 +129,7 @@ fun ComposerReplyStrip( QuoteLine(quote.snippet) } - // A large, thin ✕ rather than a small bold one, sized to sit against two lines of quote - // without crowding them, in a hit target wider than the glyph. + // A disc rather than a bare ✕, in a hit target wider than the disc. Box( modifier = Modifier .size(ComposerReplyStripDefaults.dismissTarget) @@ -99,12 +137,22 @@ fun ComposerReplyStrip( .testTag("action_cancel_reply"), contentAlignment = Alignment.Center, ) { - Icon( - modifier = Modifier.requiredSize(ComposerReplyStripDefaults.dismissGlyph), - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.action_cancelReply), - tint = CodeTheme.colors.textSecondary, - ) + Box( + modifier = Modifier + .size(ComposerReplyStripDefaults.dismissDisc) + .background( + CodeTheme.colors.textSecondary.copy(alpha = 0.35f), + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.requiredSize(ComposerReplyStripDefaults.dismissGlyph), + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.action_cancelReply), + tint = CodeTheme.colors.textMain, + ) + } } } } @@ -163,16 +211,21 @@ private fun QuoteLine(snippet: ChatQuoteSnippet) { /** Every measurement the strip makes, carried over from iOS. */ private object ComposerReplyStripDefaults { - val ruleWidth = 4.dp + val cornerRadius = 14.dp + val ruleWidth = 6.dp val gap = 9.dp val nameGap = 2.dp val cashGap = 6.dp val textPadding = 8.dp val trailingPadding = 8.dp + /** The composer field's own height, so a one-line quote does not sit shorter than it. */ + val minHeight = 50.dp + /** Sized to the cap height of the amount beside it, so the flag reads as a mark on the line. */ val flagSize = 16.dp val dismissTarget = 34.dp - val dismissGlyph = 20.dp + val dismissDisc = 22.dp + val dismissGlyph = 11.dp const val textMaxLines = 2 }