From 26e5fc91487610edc16bd1c95d5537bc2edfa1ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 01:38:56 -0400 Subject: [PATCH 1/3] fix(persistence): store the viewer's can_pin permission #1884 parsed ViewerState.Permissions.can_pin but never saved it, so every chat read it back as false. Add chat_metadata.can_pin (DB v51) and re-arm viewer_state_version on upgrade, as can_edit and can_invite did. --- .../51.json | 1231 +++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 14 +- .../app/persistence/dao/ChatMetadataDao.kt | 6 +- .../entities/ChatMetadataEntity.kt | 3 + .../mapper/chat/ChatEntityMapperTest.kt | 14 + .../sources/ChatMetadataDataSource.kt | 1 + .../sources/mapper/chat/ChatEntityMapper.kt | 4 +- 7 files changed, 1269 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/51.json diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/51.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/51.json new file mode 100644 index 0000000000..a2e576a552 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/51.json @@ -0,0 +1,1231 @@ +{ + "formatVersion": 1, + "database": { + "version": 51, + "identityHash": "8d61a035bffcc3bafa80fd4ef21d035a", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `can_silence` INTEGER NOT NULL DEFAULT 0, `can_ban` INTEGER NOT NULL DEFAULT 0, `can_pin` INTEGER NOT NULL DEFAULT 0, `silence_until_epoch_ms` INTEGER, `silence_forever` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, `pin_count` INTEGER NOT NULL DEFAULT 0, `pin_version` INTEGER NOT NULL DEFAULT 0, `latest_pin` BLOB, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canSilence", + "columnName": "can_silence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canBan", + "columnName": "can_ban", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canPin", + "columnName": "can_pin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "silenceUntilEpochMs", + "columnName": "silence_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "silenceForever", + "columnName": "silence_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "pinCount", + "columnName": "pin_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pinVersion", + "columnName": "pin_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestPin", + "columnName": "latest_pin", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + }, + { + "tableName": "payment_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `version` INTEGER NOT NULL, `status` TEXT NOT NULL, `ends_at` INTEGER, `created_at` INTEGER NOT NULL, `request` BLOB NOT NULL, PRIMARY KEY(`id_hex`))", + "fields": [ + { + "fieldPath": "idHex", + "columnName": "id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endsAtEpochMs", + "columnName": "ends_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "request", + "columnName": "request", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id_hex" + ] + }, + "indices": [ + { + "name": "index_payment_requests_chat_id_hex", + "unique": false, + "columnNames": [ + "chat_id_hex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payment_requests_chat_id_hex` ON `${TABLE_NAME}` (`chat_id_hex`)" + } + ] + }, + { + "tableName": "group_key_envelopes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `own_pk_hex` TEXT NOT NULL, `scheme` INTEGER NOT NULL, `nonce` BLOB NOT NULL, `ciphertext` BLOB NOT NULL, `wrapped_by` BLOB, `wrapped_by_public_key` BLOB, PRIMARY KEY(`chat_id_hex`, `own_pk_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownPkHex", + "columnName": "own_pk_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheme", + "columnName": "scheme", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "wrappedBy", + "columnName": "wrapped_by", + "affinity": "BLOB" + }, + { + "fieldPath": "wrappedByPublicKey", + "columnName": "wrapped_by_public_key", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "own_pk_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, '8d61a035bffcc3bafa80fd4ef21d035a')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index f19dce96a6..d71a84f82a 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -144,8 +144,9 @@ import kotlin.jvm.Synchronized // The group_key_envelopes table: wrapped chat keys, so a private group opens offline. An // AutoMigration for the same reason as payment_requests. AutoMigration(from = 49, to = 50), // group_key_envelopes table + AutoMigration(from = 50, to = 51, spec = FlipcashDatabase.Migration50To51::class), // chat_metadata.can_pin (default 0) ], - version = 50, + version = 51, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) @ConstructedBy(FlipcashDatabaseConstructor::class) @@ -327,6 +328,17 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Re-arms the viewer-state gate for the new `can_pin` column, for the reason [Migration36To37] + * gives for `can_edit`: without it Pin stays missing from the message menu on every chat stored + * before the upgrade until its viewer state version moves. + */ + class Migration50To51 : AutoMigrationSpec { + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL(Migration36To37.REARM_VIEWER_STATE) + } + } + /** * Adds `chat_messages.ciphertext_json` and `encryption_state`. A row stored before this version * with encrypted content was never opened, so it's marked for opening with its ciphertext diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index a3d86db4bc..7312a483ca 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -165,7 +165,7 @@ interface ChatMetadataDao { @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + "mute_forever = :muteForever, viewer_state_version = :version, " + - "can_edit = :canEdit, can_invite = :canInvite, can_silence = :canSilence, can_ban = :canBan, " + + "can_edit = :canEdit, can_invite = :canInvite, can_silence = :canSilence, can_ban = :canBan, can_pin = :canPin, " + "silence_until_epoch_ms = :silenceUntilEpochMs, silence_forever = :silenceForever " + "WHERE chat_id_hex = :chatIdHex AND (:version > viewer_state_version OR viewer_state_version = 0)" ) @@ -180,6 +180,7 @@ interface ChatMetadataDao { canBan: Boolean = false, silenceUntilEpochMs: Long? = null, silenceForever: Boolean = false, + canPin: Boolean = false, ) /** @@ -206,7 +207,7 @@ interface ChatMetadataDao { */ @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " + - "viewer_state_version = 0, can_edit = 0, can_invite = 0, can_silence = 0, can_ban = 0, " + + "viewer_state_version = 0, can_edit = 0, can_invite = 0, can_silence = 0, can_ban = 0, can_pin = 0, " + "silence_until_epoch_ms = NULL, silence_forever = 0 WHERE chat_id_hex = :chatIdHex" ) suspend fun clearViewerState(chatIdHex: String) @@ -281,6 +282,7 @@ interface ChatMetadataDao { canBan = entity.canBan, silenceUntilEpochMs = entity.silenceUntilEpochMs, silenceForever = entity.silenceForever, + canPin = entity.canPin, ) mergePinSummary( chatIdHex = entity.chatIdHex, diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index cd15f4d4a2..04b67b1fb1 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -74,6 +74,9 @@ data class ChatMetadataEntity( val canSilence: Boolean = false, @ColumnInfo(name = "can_ban", defaultValue = "0") val canBan: Boolean = false, + // Whether the viewer may pin and unpin messages. Same storage rules as can_edit. + @ColumnInfo(name = "can_pin", defaultValue = "0") + val canPin: Boolean = false, // A moderator's silence on the viewer. Stored the way a mute is, as a deadline or a forever // flag and never both, because a timed silence lapses with no server signal and only the // deadline compared against the clock on read stays correct. diff --git a/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 366ce64668..9e4df3e034 100644 --- a/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -642,6 +642,20 @@ class ChatEntityMapperTest { assertEquals(true, moderation?.canBan) } + @Test + fun `the pin grant survives the round trip`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState(version = 0, permissions = ViewerState.Permissions(canPin = true)) + ) + ) + assertEquals(true, entity.canPin) + + val restored = mapper.toMetadata(entity, emptyList(), null) + + assertEquals(true, restored.viewerState?.permissions?.canPin) + } + @Test fun `a timed silence survives the round trip as its deadline`() { val until = Instant.fromEpochMilliseconds(9_000) diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 2c0aaa2125..aeb2b2d1c3 100644 --- a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -138,6 +138,7 @@ class ChatMetadataDataSource @Inject constructor( canBan = viewerState.permissions.moderation.canBan, silenceUntilEpochMs = mapper.silenceUntilEpochMs(viewerState.restrictions?.silence), silenceForever = mapper.isSilenceForever(viewerState.restrictions?.silence), + canPin = viewerState.permissions.canPin, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index f414dd9ca9..36f6e829df 100644 --- a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -104,6 +104,7 @@ class ChatEntityMapper @Inject constructor() { canInvite = metadata.viewerState?.permissions?.canInvite ?: false, canSilence = metadata.viewerState?.permissions?.moderation?.canSilence ?: false, canBan = metadata.viewerState?.permissions?.moderation?.canBan ?: false, + canPin = metadata.viewerState?.permissions?.canPin ?: false, silenceUntilEpochMs = silenceUntilEpochMs(metadata.viewerState?.restrictions?.silence), silenceForever = isSilenceForever(metadata.viewerState?.restrictions?.silence), creatorHex = metadata.creator?.hexEncodedString(), @@ -195,7 +196,7 @@ class ChatEntityMapper @Inject constructor() { silenceUntil != null -> SilenceState.Until(Instant.fromEpochMilliseconds(silenceUntil)) else -> null } - if (mute == null && silence == null && viewerStateVersion == 0L && !canEdit && !canInvite && !canSilence && !canBan) { + if (mute == null && silence == null && viewerStateVersion == 0L && !canEdit && !canInvite && !canSilence && !canBan && !canPin) { return null } return ViewerState( @@ -204,6 +205,7 @@ class ChatEntityMapper @Inject constructor() { permissions = ViewerState.Permissions( canEdit = canEdit, canInvite = canInvite, + canPin = canPin, moderation = ViewerState.ModerationPermissions(canSilence = canSilence, canBan = canBan), ), restrictions = silence?.let { ViewerState.Restrictions(silence = it) }, From f96df91c00386249ad96c31b796759778acec23a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 01:38:56 -0400 Subject: [PATCH 2/3] feat(chat): pin, unpin and read pinned messages Pinned messages open through the same path as readMessage: the stored copy when there is one, else the wire copy opened for the chat's view mode. They are not written into the transcript. MessageCapability.Pin is granted from can_pin and refused on system messages, tombstones and payment requests. --- .../flipcash/shared/chat/ChatCoordinator.kt | 21 +++++++++ .../flipcash/shared/chat/MessageCapability.kt | 31 +++++++++++- .../flipcash/shared/chat/PinnedMessages.kt | 12 +++++ .../internal/delegates/MessagingDelegate.kt | 44 +++++++++++++++++ .../shared/chat/MessageCapabilityTest.kt | 47 +++++++++++++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PinnedMessages.kt 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 88b0ae0976..6a5c54e960 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 @@ -10,6 +10,8 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.InviteParameters import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.chat.PinSummary import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.ChatType @@ -484,6 +486,25 @@ interface MessagingOperations { */ suspend fun peekMessage(chatId: ChatId, messageId: Long): Result + /** + * Every pin in [chatId], most recently pinned first. Each [Pin.message] is readable the way + * [readMessage] makes it: the stored copy when there is one, otherwise the server's copy, + * opened on this device if it is end-to-end encrypted. Nothing is stored; a pinned message can + * be far older than the stored transcript. + * + * Pins whose message was deleted come back as tombstones; callers hide them. + */ + suspend fun getPinnedMessages(chatId: ChatId): Result + + /** + * Pins [messageId] in [chatId] and returns the chat's updated pin summary. Re-pinning a pinned + * message is a no-op on the server. Fails with `PinMessageError.TooManyPins` at the server's cap. + */ + suspend fun pinMessage(chatId: ChatId, messageId: Long): Result + + /** Unpins [messageId] in [chatId] and returns the chat's updated pin summary. */ + suspend fun unpinMessage(chatId: ChatId, messageId: Long): Result + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index 6079b6247e..6695a53960 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -3,6 +3,7 @@ package com.flipcash.shared.chat import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent import com.flipcash.shared.chat.reactions.SelfReaction import com.getcode.opencode.model.core.ID import com.flipcash.shared.chat.ui.linkableText @@ -48,6 +49,13 @@ enum class MessageCapability { /** Remove the message's sender from the group (a ban), under the same rules as [Silence] with `can_ban`. */ Remove, + + /** + * Pin or unpin the message, granted by `ViewerState.Permissions.can_pin` on a message that can + * be pinned ([isPinnable]). Whether the action pins or unpins is decided by the caller, which + * holds the chat's pinned ids. + */ + Pin, } /** @@ -164,8 +172,10 @@ fun resolveCapabilities( canSilence: Boolean = false, canBan: Boolean = false, creatorId: ID? = null, + canPin: Boolean = false, ): Set { val resolved = resolveForParticipant(message, policy, now, canSpeak) + .let { if (canPin && isPinnable(message)) it + MessageCapability.Pin else it } if (!canPost) return resolved.readOnly() // Report is granted on exactly the messages a participant other than the viewer sent, so it // stands in for "someone else's message" here. The creator is never offered either action. @@ -183,7 +193,23 @@ fun resolveCapabilities( * a set resolved while the viewer could post has to narrow at the menu if they have since left. */ fun Set.readOnly(): Set = filterTo(mutableSetOf()) { - it == MessageCapability.Copy || it == MessageCapability.Report || it == MessageCapability.Link + it == MessageCapability.Copy || it == MessageCapability.Report || it == MessageCapability.Link || + it == MessageCapability.Pin +} + +/** + * Whether [message] is something a pin can point at. The server refuses system messages and + * tombstones (`MESSAGE_NOT_ALLOWED`) and an unacknowledged message has no id to pin. Payment + * requests are refused here only: the request strip and pill already keep them at the top. + */ +fun isPinnable(message: ChatMessage): Boolean { + val contents = message.content + if (contents.isEmpty()) return false + if (message.eventSequence == 0L) return false + if (contents.any { it is MessageContent.Deleted }) return false + if (contents.all { it is MessageContent.System }) return false + if (contents.any { it is MessageContent.Widget && it.widget is WidgetContent.PaymentRequest }) return false + return true } private fun resolveForParticipant( @@ -274,7 +300,8 @@ fun Set.withinWindows( // server enforces them, and nothing in the contract limits how old a reportable message // may be. MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report, - MessageCapability.Link, MessageCapability.Silence, MessageCapability.Remove -> true + MessageCapability.Link, MessageCapability.Silence, MessageCapability.Remove, + MessageCapability.Pin -> true } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PinnedMessages.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PinnedMessages.kt new file mode 100644 index 0000000000..e1f9c2d072 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PinnedMessages.kt @@ -0,0 +1,12 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.Pin + +/** + * Every pin in a chat, most recently pinned first, and the `PinSummary.version` they were read at. + * The version is what a later summary is compared against to know the list is out of date. + */ +data class PinnedMessages( + val pins: List, + val version: Long, +) 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 33c9de193c..4480c866ef 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 @@ -33,6 +33,9 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.services.models.PagingToken import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType @@ -49,6 +52,7 @@ import com.flipcash.shared.chat.internal.SenderResolver import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation +import com.flipcash.shared.chat.PinnedMessages import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.OutgoingEncryption @@ -419,6 +423,46 @@ class MessagingDelegate @Inject constructor( override suspend fun peekMessage(chatId: ChatId, messageId: Long): Result = messagingController.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED) + override suspend fun getPinnedMessages(chatId: ChatId): Result = runCatching { + val pins = mutableListOf() + var token: PagingToken? = null + var version: Long? = null + do { + val page = chatController.getPinnedMessages( + chatId, + QueryOptions(token = token), + ViewMode.FULL_OR_REDACTED, + ).getOrThrow() + pins += page.pins + // The first page's: a later page read after another pin landed must not claim a version + // whose pins this list doesn't hold. + if (version == null) version = page.pinSummary.version + token = page.pagingToken + } while (page.hasMore && token != null) + PinnedMessages( + pins = pins.map { pin -> pin.copy(message = readablePinned(chatId, pin.message)) }, + version = version ?: 0, + ) + } + + /** The stored copy, opened on upsert, when there is one; otherwise [message] opened the way [readMessage] does. */ + private suspend fun readablePinned(chatId: ChatId, message: ChatMessage): ChatMessage { + messageDataSource.getMessage(chatId, message.messageId)?.let { return it } + val opener = incoming + val selfId = userManager.accountId + if (opener == null || selfId == null || message.content.singleOrNull() !is MessageContent.Encrypted) { + return message + } + val peerId = if (message.senderId == selfId) getOtherMember(chatId)?.userId else null + return opener.open(chatId, selfId, peerId, listOf(message), stored = { null }).single() + } + + override suspend fun pinMessage(chatId: ChatId, messageId: Long): Result = + chatController.pinMessage(chatId, messageId) + + override suspend fun unpinMessage(chatId: ChatId, messageId: Long): Result = + chatController.unpinMessage(chatId, messageId) + /** Text, photos and replies to either are what DMs encrypt; anything else keeps the server's body. */ private fun MessageContent.pushText(): String? = when (this) { is MessageContent.Text -> text diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index 46cbc5e1b3..e55990667f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -619,4 +619,51 @@ class MessageCapabilityTest { assertEquals(false, canBoost(text(isFromSelf = false), ChatType.GROUP, selfReactions = reacted, canSpeak = false)) assertEquals(false, canBoost(text(isFromSelf = false, eventSequence = 0), ChatType.GROUP, selfReactions = reacted)) } + + // can_pin is the whole grant: who may pin is the server's call, read from the viewer state. + @Test + fun `pin is offered on a message with can_pin and withheld without it`() { + val incoming = text(isFromSelf = false) + + assertEquals(true, MessageCapability.Pin in resolveCapabilities(incoming, now = sentAt, canPin = true)) + assertEquals(false, MessageCapability.Pin in resolveCapabilities(incoming, now = sentAt, canPin = false)) + } + + @Test + fun `pin survives losing the right to post`() { + val resolved = resolveCapabilities(text(), now = sentAt, canPost = false, canPin = true) + + assertEquals(true, MessageCapability.Pin in resolved) + } + + @Test + fun `pin is offered on cash`() { + assertEquals(true, MessageCapability.Pin in resolveCapabilities(cash(), now = sentAt, canPin = true)) + } + + // The server refuses these with MESSAGE_NOT_ALLOWED; the menu shouldn't offer what fails. + @Test + fun `pin is hidden on system messages and tombstones`() { + val system = message(listOf(MessageContent.System("Anna joined")), isFromSelf = false) + val tombstone = message(listOf(MessageContent.Deleted(sentAt, otherId)), isFromSelf = false) + + assertEquals(false, MessageCapability.Pin in resolveCapabilities(system, now = sentAt, canPin = true)) + assertEquals(false, MessageCapability.Pin in resolveCapabilities(tombstone, now = sentAt, canPin = true)) + } + + // Client-only: the request strip and pill already keep requests at the top. + @Test + fun `pin is hidden on payment requests`() { + val request = message(listOf(MessageContent.Widget(WidgetContent.PaymentRequest(listOf(7))))) + + assertEquals(false, MessageCapability.Pin in resolveCapabilities(request, now = sentAt, canPin = true)) + } + + @Test + fun `pin is hidden on a message the server hasn't acknowledged`() { + assertEquals( + false, + MessageCapability.Pin in resolveCapabilities(text(eventSequence = 0), now = sentAt, canPin = true), + ) + } } From c5470350b820be00f95217098c7fb4845652b15c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 15:11:39 -0400 Subject: [PATCH 3/3] feat(messenger): pin bar, pinned list and swipe to dock the request strip The pin bar draws the pinned message with the reply strip's quote, now shared from chat-ui as ChatQuoteContent, without the strip's 8dp text padding and with a 10dp rule gap, as iOS's PinBar does. Its edges line up with the back button and the top bar's pin button, and it slides down from the top as it fades in. With more than one pin, the author rule splits into segments stacked bottom up from the latest, the shown pin lit. A 34dp window holds two segments; past two, the track scrolls to keep the lit one in view. The bar's list button and the top-bar pin action use the list-and-pin glyph. The pinned list is a ChatStep.PinnedMessages sheet destination. It takes the chat id and, after a refused pin, the message waiting for a slot. Rows come from the shared ChatViewModel's pins, and a row tap returns PinnedMessageJump through navigateForResult. The title stays put while rows scroll under it, with a progressive blur at the top and bottom edges, and unpinned rows animate out. In a row, only the bubble and Unpin take a tap. The bubble claims the down on the Initial pass, so its own links and buttons never fire in the sheet, and it exposes one click action labelled "Jump to message". Unpin keeps a 48dp target laid out below the bubble. PinnedMessageRowTest asserts those two actions and drives real taps on the name, meta line and the bubble's link. Swiping the request strip up docks it into the pill. The top-bar pin button leaves the composition on the first collapsing frame, because AnimatedVisibility kept it laid out beside the pill's slot for three frames even with ExitTransition.None. The top-bar pin button sits in the app bar's action slot, so it is as far from the right edge as back is from the left (45px each on the Seeker), and it fades and scales in. The pinned list's header is the reactors sheet's: AppBarWithTitle with a centred title and AppBarDefaults.Close at the end, which pops the sheet in every state. The close is drawn at 32dp, so expandedTouchTarget grows its touch area to 48dp; PinnedMessagesSheetTest taps 23dp out from its centre in the normal, read-only and full lists. --- .../src/androidMain/res/values/strings.xml | 15 + .../com/flipcash/app/core/chat/ChatStep.kt | 20 ++ .../flipcash/app/messenger/ChatFlowScreen.kt | 41 +++ .../app/messenger/internal/ChatViewModel.kt | 280 ++++++++++++++- .../app/messenger/internal/pins/ChatPins.kt | 164 +++++++++ .../internal/request/DeckHostState.kt | 13 + .../messenger/internal/request/RequestDeck.kt | 25 +- .../internal/screens/MessengerScreen.kt | 88 +++++ .../internal/screens/components/ChatTopBar.kt | 31 +- .../internal/screens/components/PinBar.kt | 232 +++++++++++++ .../screens/components/PinnedMessagesSheet.kt | 324 ++++++++++++++++++ .../main/res/drawable/ic_list_bullet_pin.xml | 31 ++ .../messenger/internal/ChatEventStallTest.kt | 1 + .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatGroupCashLinkTest.kt | 1 + .../internal/ChatMentionPickerTest.kt | 1 + .../internal/ChatOpenTranscriptTest.kt | 1 + .../internal/ChatPreviewsReadyTest.kt | 2 + .../internal/ChatReturnToOriginTest.kt | 1 + .../internal/ChatSendFailureAnalyticsTest.kt | 1 + .../internal/ChatSilenceLiftedToastTest.kt | 1 + .../messenger/internal/ChatTypingGateTest.kt | 1 + .../messenger/internal/pins/ChatPinsTest.kt | 230 +++++++++++++ .../internal/request/DeckDockTest.kt | 92 +++++ .../components/PinnedMessageRowTest.kt | 160 +++++++++ .../components/PinnedMessagesSheetTest.kt | 114 ++++++ .../shared/chat/ui/ChatSummaryMapping.kt | 3 +- .../shared/chat/ui/ComposerReplyStrip.kt | 106 ++++-- .../flipcash/shared/chat/ui/MessageBubble.kt | 5 +- .../com/getcode/ui/components/TitleBar.kt | 7 + 30 files changed, 1949 insertions(+), 43 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/pins/ChatPins.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinBar.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheet.kt create mode 100644 apps/flipcash/features/messenger/src/main/res/drawable/ic_list_bullet_pin.xml create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/pins/ChatPinsTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckDockTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessageRowTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheetTest.kt diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 47559d7ee7..8d17b0e5a3 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1832,6 +1832,21 @@ Share Profile Share Message Forward + Pin + Unpin + Jump to message + Pinned message + Pinned message from %1$s: %2$s + Encrypted message + Pinned Messages + Pinned by %1$s · %2$s + you + Pinned messages are full. Unpin one to pin \"%1$s\" + Pinned messages are full. Unpin one to pin this message + Couldn\'t pin message + Couldn\'t unpin message + Couldn\'t find that message + Pinned messages Show Profile Card Copy Link diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt index edcdcf301c..69c637abc3 100644 --- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -42,6 +42,11 @@ enum class InviteLinkAction : Parcelable { Share, Revoke } @Serializable data class NewInviteLinkChoice(val expiry: String, val uses: String) : Parcelable +/** The pinned message tapped in [ChatStep.PinnedMessages]: the chat jumps to it once the list closes. */ +@Parcelize +@Serializable +data class PinnedMessageJump(val messageId: Long) : Parcelable + /** A custom paid-reaction amount, in whole US cents. */ @Parcelize @Serializable @@ -357,6 +362,21 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet + + /** + * The chat's pinned messages, newest first (L1). The pins are the conversation's, read when the + * chat opened; the route names the chat only. [pinAfterUnpin] is set when it opened because the + * chat is at its pin cap (L1b): that message is pinned once an unpin frees a slot. + * + * Returns the tapped row as a [PinnedMessageJump]; the conversation jumps to it. + */ + @Parcelize + @Serializable + data class PinnedMessages(val chatId: ChatId, val pinAfterUnpin: Long? = null) : + ChatStep, + NavigationRetVal, + Sheet, + HalfSheet } /** What opened a [ChatStep.Profile], which decides the actions it offers. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index b72d1fd13c..4680aa9b02 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -44,6 +45,10 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.core.chat.InviteLinkAction import com.flipcash.app.core.chat.NewInviteLinkChoice +import com.flipcash.app.core.chat.PinnedMessageJump +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.app.messenger.internal.pins.pinnedListDone +import com.flipcash.app.messenger.internal.screens.components.PinnedMessagesSheet import com.flipcash.app.messenger.internal.invites.InviteLinkActions import com.flipcash.app.messenger.internal.invites.NewInviteLinkContent import com.flipcash.app.messenger.internal.invites.toChoice @@ -334,6 +339,9 @@ private fun chatEntryProvider( annotatedEntry { step -> FlowReactorsScreen(step.chatId, step.messageId) } + annotatedEntry { step -> + FlowPinnedMessagesSheet(step.pinAfterUnpin) + } } @Composable @@ -837,6 +845,39 @@ private fun FlowReactionPickerScreen(messageId: Long) { ) } +/** + * [ChatStep.PinnedMessages]: the conversation's pins on the app's sheet scene (L1, L1c, and L1b when + * [pinAfterUnpin] is set). The pins are the conversation's own, so this reads its view model; a + * tapped row closes the sheet and returns to the conversation as a [PinnedMessageJump]. + */ +@Composable +private fun FlowPinnedMessagesSheet(pinAfterUnpin: Long?) { + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) + + // Built off the main thread: a cash row looks up its token before it can draw. + val rows by produceState?>(null, state.pins) { value = viewModel.pinnedRows(state.pins) } + // The last rows stay up while the sheet slides away, so it never empties on the way out. + var shownRows by remember { mutableStateOf>(emptyList()) } + rows?.takeIf { it.isNotEmpty() }?.let { shownRows = it } + + // The last unpin, or the waiting message landing in the slot it freed, leaves nothing to do here. + val done = state.pins.pinnedListDone(pinAfterUnpin) + LaunchedEffect(done) { if (done) dismissSheet() } + + PinnedMessagesSheet( + rows = shownRows, + canUnpin = state.canPin, + full = pinAfterUnpin != null, + fullQuote = state.waitingPin?.takeIf { it.messageId == pinAfterUnpin }?.quote, + onOpen = { resultBack.returnValue(PinnedMessageJump(it)) }, + onUnpin = { viewModel.dispatchEvent(ChatViewModel.Event.UnpinMessage(it, pinAfter = pinAfterUnpin)) }, + onClose = { dismissSheet() }, + ) +} + /** * Who reacted to [messageId], and with what, from the entry's own [ReactorsViewModel]. Tapping a * named row dismisses this sheet, then opens that person's profile on the chat. 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 96244bbe24..67cd3d42ca 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 @@ -7,6 +7,7 @@ import com.flipcash.shared.chat.ui.settledUsd import com.flipcash.shared.chat.ui.partFor import com.flipcash.shared.chat.media.ChatPhoto import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.LinkPreview import com.flipcash.shared.chat.ui.linkPreview import android.content.ClipboardManager @@ -98,6 +99,12 @@ import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.PinMessageError +import com.flipcash.app.messenger.internal.pins.ChatPins +import com.flipcash.app.messenger.internal.pins.WaitingPin +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.shared.chat.ui.previewText import com.flipcash.services.models.chat.isActiveAt import com.flipcash.services.models.chat.silence import com.flipcash.services.models.chat.isDmAddressable @@ -327,6 +334,9 @@ internal class ChatViewModel @AssistedInject constructor( updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { + /** The highest pin summary version heard of; a read follows any that passes the held list. */ + private val pinVersionHeard = MutableStateFlow(null) + /** * Whether the transcript may be drawn. Saved link previews load at app start, but a chat opened @@ -382,6 +392,12 @@ internal class ChatViewModel @AssistedInject constructor( * needs the deadline this keeps. */ val viewerState: ViewerState? = null, + /** The chat's pins, read on open and again whenever the stored summary moves past them. */ + val pins: ChatPins = ChatPins(), + /** Which visible pin the bar shows, newest first. A tap jumps to it and steps to the next older. */ + val pinBarIndex: Int = 0, + /** The message the server refused at the pin cap; the pinned list quotes it while it waits for a slot. */ + val waitingPin: WaitingPin? = null, /** * The clock a timed silence is read against. A silence lapses with no event, so [silence] * compares the deadline to this and a timer bumps it at the deadline. @@ -655,6 +671,10 @@ internal class ChatViewModel @AssistedInject constructor( val participant: ChatParticipant? get() = subject?.asParticipant() + /** `can_pin`: the creator of a group while a member, either member of a DM. Read, never derived from the type. */ + val canPin: Boolean + get() = viewerState?.permissions?.canPin == true + /** * Cash sent here pays members rather than posting a cash link. Private groups only: they * are where the client holds the whole roster. @@ -988,6 +1008,26 @@ internal class ChatViewModel @AssistedInject constructor( /** This chat's viewer state moved, from the stream or from the viewer's own request. */ data class OnViewerStateResolved(val viewerState: ViewerState?) : Event + /** A read of the chat's pins came back, made at summary [version]. */ + data class PinsRead(val pins: List, val version: Long) : Event + + /** The transcript's stored copies of the pinned messages, keyed by message id. */ + data class PinnedCopiesStored(val messages: Map) : Event + + /** The stored pin summary moved; its count says whether there is anything to wait for. */ + data class PinSummaryChanged(val count: Int) : Event + + /** Pin [messageId]; [quote] names it in the full-list header if the chat is at its cap. */ + data class PinMessage(val messageId: Long, val quote: String?) : Event + /** Unpin [messageId]; once it succeeds, [pinAfter] takes the freed slot (the list opened full). */ + data class UnpinMessage(val messageId: Long, val pinAfter: Long? = null) : Event + + /** The server refused a pin with `TOO_MANY_PINS`: the screen opens the list to make room for it. */ + data class PinCapReached(val waiting: WaitingPin) : Event + + /** The pin bar was tapped: jump to the pin it shows and step to the next older one. */ + data object PinBarTapped : Event + /** A timed silence reached its deadline; nothing else says so. */ data object SilenceLapsed : Event @@ -1336,6 +1376,8 @@ internal class ChatViewModel @AssistedInject constructor( val canBan: Boolean, /** The group's creator, whom no moderation action may name. */ val creatorId: ID?, + /** `can_pin`, read from the viewer state for DMs and groups alike. */ + val canPin: Boolean, ) /** @@ -1554,6 +1596,7 @@ internal class ChatViewModel @AssistedInject constructor( canSilence = it.chatType == ChatType.GROUP && moderation?.canSilence == true, canBan = it.chatType == ChatType.GROUP && moderation?.canBan == true, creatorId = (it.subject as? ChatSubject.Group)?.creator, + canPin = it.canPin, ) }.distinctUntilChanged(), ::ViewerGates, @@ -1587,12 +1630,7 @@ internal class ChatViewModel @AssistedInject constructor( // message is still in the page: its key, and so the next page's, are unchanged. if (content.paysRequestShare) return@flatMapIndexed emptyList() - val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) { - val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token - if (token != null) { - content.copy(tokenName = token.name, tokenImageUrl = token.imageUrl) - } else content - } else content + val enriched = content.withTokenMetadata() // 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 @@ -1698,6 +1736,7 @@ internal class ChatViewModel @AssistedInject constructor( canSilence = facts.canSilence, canBan = facts.canBan, creatorId = facts.creatorId, + canPin = facts.canPin, ), quote = quote, sender = sender, @@ -3409,6 +3448,14 @@ internal class ChatViewModel @AssistedInject constructor( } .launchIn(viewModelScope) + // A jump that can't land says so: a pin or a link to a message the server no longer + // returns would otherwise be a tap that does nothing. + eventFlow.filterIsInstance() + .onEach { toastController.showToast(R.string.title_messageUnreachable, replacePrevious = true) } + .launchIn(viewModelScope) + + observePins() + // Only distanceFromNewest, not resolveJump: an origin was on screen when its quote was // tapped, so one no longer stored has gone from the chat, and is skipped rather than fetched. eventFlow.filterIsInstance() @@ -4200,6 +4247,209 @@ internal class ChatViewModel @AssistedInject constructor( * device never stored is paged in from the server first, back from the oldest stored message so * the transcript stays contiguous; a link to old history is the usual case. */ + /** + * Pins are read with `GetPinnedMessages` on open and read again whenever a newer + * `PinSummary.version` is heard of: from the stored summary, which the event stream advances on + * `PinsChanged`, or from the viewer's own pin or unpin. The list is never patched from an event. + * A summary missing from a feed read leaves the stored one alone, so it never clears pins here. + */ + private fun observePins() { + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> chatCoordinator.observeMetadata(chatId) } + .mapNotNull { it?.metadata?.pinSummary } + .distinctUntilChanged() + .onEach { summary -> + dispatchEvent(Event.PinSummaryChanged(summary.count)) + notePinVersion(summary.version) + } + .launchIn(viewModelScope) + + // Restarted by each newer version, so a read overtaken by another pin is dropped for the next. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> pinVersionHeard.map { chatId to it } } + .filter { (_, heard) -> stateFlow.value.pins.let { !it.loaded || it.isStale(heard) } } + .mapLatest { (chatId, _) -> + chatCoordinator.getPinnedMessages(chatId) + .onSuccess { dispatchEvent(Event.PinsRead(it.pins, it.version)) } + .onFailure { trace(tag = "Pins", message = "Pin read failed", error = it, type = TraceType.Error) } + } + .launchIn(viewModelScope) + + // A delete or edit of a pinned message moves no pin version; its stored copy shows it. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> + combine( + chatCoordinator.observeMessages(chatId), + stateFlow.map { it.pins.pinnedIds }.distinctUntilChanged(), + ) { messages, ids -> messages.filter { it.messageId in ids }.associateBy { it.messageId } } + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.PinnedCopiesStored(it)) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> pin(event.messageId, event.quote) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + chatCoordinator.unpinMessage(chatId, event.messageId) + .onSuccess { summary -> + notePinVersion(summary.version) + // The list opened full: the slot just freed goes to the message that was waiting. + event.pinAfter?.let { waiting -> pin(waiting, quote = null, waited = true) } + } + .onFailure { toastController.showToast(R.string.title_unpinFailed, replacePrevious = true) } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + // The reducer has already stepped the bar on, so the pin it showed is one back. + val state = stateFlow.value + val shown = state.pins.visible.getOrNull( + (state.pinBarIndex - 1).mod(state.pins.visible.size.coerceAtLeast(1)) + ) ?: return@onEach + dispatchEvent(Event.JumpToMessage(shown.messageId)) + } + .launchIn(viewModelScope) + + } + + private fun notePinVersion(version: Long) { + pinVersionHeard.update { heard -> maxOf(heard ?: version, version) } + } + + /** + * The pin bar's one line for [pin]: the chat list's preview of the message, with no + * attribution. Null when there is nothing readable to quote, which the bar says as an + * encrypted message. + */ + internal fun pinPreview(pin: Pin): String? { + val isGroup = stateFlow.value.chatType == ChatType.GROUP + return pin.message.content.firstNotNullOfOrNull { content -> + content.previewText( + sentBySelf = false, + senderName = null, + isGroup = isGroup, + tokensByMint = emptyMap(), + resources = resources, + exchange = exchange, + paymentRequests = emptyMap(), + ) { null } + } + } + + /** + * The pin bar's quote for [pin], built the way the composer's reply strip quotes a message so + * the two read alike. A message with nothing readable quotes as an encrypted message. + */ + internal suspend fun pinQuote(pin: Pin): ChatQuote { + val quote = pin.message.toQuote() + val unreadable = pinPreview(pin) == null && + (quote.snippet as? ChatQuoteSnippet.Text)?.body.isNullOrEmpty() + return if (unreadable) { + quote.copy(snippet = ChatQuoteSnippet.Text(resources.getString(R.string.label_pinnedEncryptedMessage))) + } else { + quote + } + } + + /** A cash message names its token only by mint; the bubble's header needs the name and image. */ + private suspend fun MessageContent.withTokenMetadata(): MessageContent { + if (this !is MessageContent.Cash || tokenName.isNotBlank()) return this + val token = tokenCoordinator.getTokenMetadata(mint).getOrNull()?.token ?: return this + return copy(tokenName = token.name, tokenImageUrl = token.imageUrl) + } + + /** + * The pinned list's rows, drawn with the transcript's bubble, so a pinned message reads the same + * as it does in place: cash gets its token and the viewer's side of the payment the same way. + */ + internal suspend fun pinnedRows(pins: ChatPins): List { + val selfId = userManager.accountId + return pins.visible.mapNotNull { pin -> + val message = pin.message + val content = message.content.firstOrNull()?.withTokenMetadata() ?: return@mapNotNull null + PinnedRow( + pin = pin, + bubble = ChatListItem.ContentBubble( + messageId = message.messageId, + contentIndex = 0, + content = content, + isFromSelf = message.isFromSelf, + timestamp = message.timestamp, + isEdited = message.lastEditedTs != null, + receivedPart = (content as? MessageContent.Cash)?.partFor(selfId), + groupSendRecipients = (content as? MessageContent.Cash) + ?.takeIf { stateFlow.value.chatType == ChatType.GROUP } + ?.let { groupSendRecipients(it, senderProfiles.value) }, + ), + sender = pinPerson(message.senderId), + pinnedByName = if (pin.pinnedBy == selfId) { + resources.getString(R.string.label_pinnedByYou) + } else { + pinPerson(pin.pinnedBy).displayName + }, + spoken = pinPreview(pin), + ) + } + } + + /** + * A member as a pinned row shows them: the viewer's own, a group member's profile, or a DM's other + * member. The viewer reads as "You" but their avatar keeps their own initials. + */ + @OptIn(ExperimentalStdlibApi::class) + private fun pinPerson(userId: ID?): SenderIdentity { + val state = stateFlow.value + val id = userId ?: emptyList() + if (userId != null && userId == userManager.accountId) { + val profile = userManager.profile + return SenderIdentity( + userId = id, + displayName = resources.getString(R.string.title_you), + picture = profile?.profilePicture, + initialsName = profile?.let { nameOrHandle(it.displayName, it.handle) }, + ) + } + if (state.chatType != ChatType.GROUP) { + (state.participant as? ChatParticipant.TipUser)?.let { other -> + return SenderIdentity( + userId = id, + displayName = other.name ?: resources.getString(R.string.title_unnamedUser), + picture = other.profile.profilePicture, + initialsName = other.name, + ) + } + } + val person = groupSendPerson(userId?.hexEncodedString()) + return SenderIdentity(userId = id, displayName = person.name, picture = person.profile?.profilePicture) + } + + /** + * Pins [messageId]. [waited] is set when it is the message the full list was opened for; the + * list closes on its own once the fresh read shows it pinned. + */ + private suspend fun pin(messageId: Long, quote: String?, waited: Boolean = false) { + val chatId = stateFlow.value.chatId ?: return + chatCoordinator.pinMessage(chatId, messageId) + .onSuccess { summary -> notePinVersion(summary.version) } + .onFailure { error -> + // At the cap the list opens to make room. If it was already open for this message, a + // concurrent pin took the slot: say so and leave the list up for another try. + if (error is PinMessageError.TooManyPins && !waited) { + dispatchEvent(Event.PinCapReached(WaitingPin(messageId, quote))) + } else { + toastController.showToast(R.string.title_pinNotAdded, replacePrevious = true) + } + } + } + private suspend fun resolveJump(chatId: ChatId, messageId: Long) { val distance = chatCoordinator.distanceFromNewest(chatId, messageId) ?: if (chatCoordinator.loadHistoryThrough(chatId, messageId)) { @@ -4334,6 +4584,24 @@ internal class ChatViewModel @AssistedInject constructor( is Event.OnViewerStateResolved -> { state -> state.copy(viewerState = event.viewerState, silenceClock = kotlin.time.Clock.System.now()) } + is Event.PinsRead -> { state -> + val pins = state.pins.read(event.pins, event.version) + // A pin that left can take the bar's place out from under it; start over at the newest. + val index = state.pinBarIndex.takeIf { it < pins.visible.size } ?: 0 + state.copy(pins = pins, pinBarIndex = index) + } + is Event.PinnedCopiesStored -> { state -> + val pins = state.pins.copy(stored = event.messages) + val index = state.pinBarIndex.takeIf { it < pins.visible.size } ?: 0 + state.copy(pins = pins, pinBarIndex = index) + } + is Event.PinSummaryChanged -> { state -> + state.copy(pins = state.pins.copy(summaryCount = event.count)) + } + is Event.PinMessage -> { state -> state.copy(selection = null, confirmingDelete = false) } + is Event.UnpinMessage -> { state -> state.copy(selection = null, confirmingDelete = false) } + is Event.PinCapReached -> { state -> state.copy(waitingPin = event.waiting) } + Event.PinBarTapped -> { state -> state.copy(pinBarIndex = state.pins.stepAfter(state.pinBarIndex)) } Event.SilenceLapsed -> { state -> state.copy(silenceClock = kotlin.time.Clock.System.now()) } Event.SilenceLifted -> { state -> state } Event.CopyInviteLink, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/pins/ChatPins.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/pins/ChatPins.kt new file mode 100644 index 0000000000..e3246c42de --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/pins/ChatPins.kt @@ -0,0 +1,164 @@ +package com.flipcash.app.messenger.internal.pins + +import com.flipcash.app.messenger.internal.request.DeckPlacement +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity + +/** + * The open chat's pins as `GetPinnedMessages` last returned them, newest pin first. + * + * The list is read once on open and again whenever the chat's [PinSummary.version] passes the one + * it was read at; nothing here builds pins from stream events. `PinsChanged` is best-effort, so a + * version is a signal to read again rather than a change to apply. + * + * @property version the summary version [pins] were read at, or null before the first read. + * @property summaryCount the stored summary's pin count, which includes deleted messages. Only says + * whether there is anything to fetch; what shows is counted from [visible]. + * @property stored the transcript's stored copies of pinned messages. Deleting or editing a pinned + * message moves no pin version, so the read's copy goes stale; a stored copy wins over it. + */ +internal data class ChatPins( + val version: Long? = null, + val pins: List = emptyList(), + val summaryCount: Int = 0, + val stored: Map = emptyMap(), +) { + val loaded: Boolean get() = version != null + + /** Every pinned id, tombstones included: what decides whether the menu offers Pin or Unpin. */ + val pinnedIds: Set = pins.mapTo(mutableSetOf()) { it.messageId } + + /** + * The pins that show, each with its newest copy: a message deleted after it was pinned stays + * pinned but is never drawn. + */ + val visible: List = pins + .map { pin -> stored[pin.messageId]?.let { pin.copy(message = it) } ?: pin } + .filterNot { it.message.isTombstone } + + /** + * Whether a stored summary at [summaryVersion] is newer than what is held. A null summary is a + * feed or list read that left `pin_summary` unset, which says nothing about pins. + */ + fun isStale(summaryVersion: Long?): Boolean = + summaryVersion != null && (version == null || summaryVersion > version) + + /** Takes a read made at [readVersion], unless one at a later version already landed. */ + fun read(pins: List, readVersion: Long): ChatPins = + if (version != null && readVersion < version) this else copy(version = readVersion, pins = pins) + + /** What the bar shows when it points at the [index]th visible pin, or null when it hides. */ + fun bar(index: Int): PinBar? { + if (!loaded) return if (summaryCount > 0) PinBar.Fetching else null + if (visible.isEmpty()) return null + val at = index.coerceIn(0, visible.lastIndex) + return PinBar.Showing(pin = visible[at], position = at + 1, of = visible.size) + } + + /** + * The index the bar moves to after a tap on the pin at [index]: the next older pin, wrapping + * back to the newest after the oldest. + */ + fun stepAfter(index: Int): Int = if (visible.isEmpty()) 0 else (index + 1) % visible.size +} + +/** What the pin bar draws. */ +internal sealed interface PinBar { + /** Pins exist but the list has not come back yet. */ + data object Fetching : PinBar + + /** + * [pin] is the [position]th newest of the [of] pins that show. Newest-first, so position 1 is + * the latest pin. + */ + data class Showing(val pin: Pin, val position: Int, val of: Int) : PinBar +} + +/** + * The pin bar's segmented rule for the [PinBar.Showing.position]th of [PinBar.Showing.of] pins, or + * null with one pin, which keeps the single full-height rule. + * + * Segments stack bottom up: segment 1, the latest pin, is the bottom one, and each tap lights the + * one above. The rule's window fits [SEGMENT_WINDOW] of them, so past that the track scrolls just far + * enough to keep the lit one in view (Figma Cycling variant, node 13016:87: pin 2 of 3 lit at the + * top of the window, the latest below it, pin 3 scrolled out above). + */ +internal fun PinBar.Showing.segments(): PinSegments? { + if (of < 2) return null + val lowest = (position - SEGMENT_WINDOW + 1).coerceIn(1, of - SEGMENT_WINDOW + 1) + return PinSegments(count = of, lit = position, lowest = lowest) +} + +/** + * @property count every segment on the track, one per visible pin. + * @property lit the segment drawn in the author's colour, counted from the bottom. + * @property lowest the bottom segment in the window, counted from the bottom; the track sits + * `lowest - 1` segments down. + */ +internal data class PinSegments(val count: Int, val lit: Int, val lowest: Int) { + /** The segments in the window, bottom first. */ + val visible: IntRange get() = lowest..minOf(count, lowest + SEGMENT_WINDOW - 1) +} + +internal const val SEGMENT_WINDOW = 2 + +/** Where pins sit in the chat's chrome. */ +internal enum class PinSlot { + /** Nothing pinned that shows. */ + None, + + /** The bar under the top bar (C1, C6b, C7b). */ + Bar, + + /** A pin button in the top bar's trailing slot that opens the list (C6). */ + TopBarButton, +} + +/** + * Where pins go given the request deck's resting place. Payment requests win the space under the + * top bar: while the deck rests as the strip, pins step aside to the top bar's trailing slot, free + * because the request isn't the pill. Once the deck rests as the pill, by scroll or by a swipe up on + * the strip, the pill holds the trailing slot and the bar comes back. They swap; they never stack. + * + * @param hasPins whether the bar has something to show, fetching included. + * @param deckRest where the request deck rests, or null when the chat has no requests drawn. + * @param collapsing whether the strip is partway into the pill. The pill grows into the trailing + * slot from the first pixel, so the button leaves then; the bar waits until the pill is at rest. + */ +internal fun pinSlot(hasPins: Boolean, deckRest: DeckPlacement?, collapsing: Boolean = false): PinSlot = when { + !hasPins -> PinSlot.None + deckRest == DeckPlacement.Strip && collapsing -> PinSlot.None + deckRest == DeckPlacement.Strip -> PinSlot.TopBarButton + else -> PinSlot.Bar +} + +internal val ChatMessage.isTombstone: Boolean + get() = content.any { it is MessageContent.Deleted } + +/** + * Whether the pinned list has nothing left to do after a fresh read. Opened for a message waiting + * on a slot ([pinAfterUnpin]), it is done once that message is pinned; otherwise once nothing + * visible is left in it. + */ +internal fun ChatPins.pinnedListDone(pinAfterUnpin: Long?): Boolean = + if (pinAfterUnpin != null) pinAfterUnpin in pinnedIds else visible.isEmpty() + +/** A message whose pin the server refused at the cap; it is pinned once the viewer frees a slot. */ +internal data class WaitingPin(val messageId: Long, val quote: String?) + +/** + * One row of the pinned list: the message as the transcript would draw it, who sent it, and who + * pinned it. + */ +internal data class PinnedRow( + val pin: Pin, + val bubble: ChatListItem.ContentBubble, + val sender: SenderIdentity, + val pinnedByName: String, + /** The message as one line, what a screen reader says for the bubble. */ + val spoken: String? = null, +) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt index fb9c402341..4d0523489a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt @@ -102,6 +102,19 @@ internal class DeckHostState(owed: Set) { } } + /** + * A swipe up on the strip docks it into the pill without scrolling: the same collapse a scroll + * drives, by however far the finger has moved up ([deltaPx] negative) or back down. + */ + fun dragDock(deltaPx: Float) { + if (!following) return + collapsePx = (collapsePx - deltaPx).coerceIn(0f, stripPx) + if (collapsePx >= stripPx) pin() + } + + /** A dock swipe let go: past halfway it completes and holds for the visit, short of it the strip snaps back. */ + suspend fun releaseDock() = settle() + /** Finishes a collapse released partway, toward whichever end is nearer. */ private suspend fun settle() { val from = collapsePx diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt index 180fd31615..e4be646b99 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt @@ -251,6 +251,10 @@ internal fun RequestDeckStrip( onCycle: () -> Unit, onPay: (PaymentRequest) -> Unit, onHeightChange: (Int) -> Unit, + /** A swipe up on the strip, by however far it moved: docks it into the pill without a scroll. */ + onDock: (Float) -> Unit, + /** The dock swipe let go. */ + onDockReleased: suspend () -> Unit, modifier: Modifier = Modifier, ) { var shown by remember { mutableStateOf(deck) } @@ -283,6 +287,8 @@ internal fun RequestDeckStrip( onExpand = onExpand, onCycle = onCycle, onPay = onPay, + onDock = onDock, + onDockReleased = onDockReleased, ) } } @@ -306,8 +312,8 @@ private val DeckPlacement.deckStyle: DeckStyle /** * The front request with up to two cards peeking beneath it, in one of the deck's placements. - * Swipe down cycles; tap expands. Shown while [transition] is at [style], whose scope its front - * card moves between placements in. + * Swipe down cycles; tap expands. On the strip a swipe up docks it ([onDock]). Shown while + * [transition] is at [style], whose scope its front card moves between placements in. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -320,6 +326,8 @@ private fun DeckStack( onExpand: () -> Unit, onCycle: () -> Unit, onPay: (PaymentRequest) -> Unit, + onDock: ((Float) -> Unit)? = null, + onDockReleased: (suspend () -> Unit)? = null, ) { transition.AnimatedVisibility( visible = { it == style }, @@ -342,6 +350,9 @@ private fun DeckStack( val pillHeightPx = with(density) { geometry.height.toPx() } val canCycle = deck.size > 1 val pillWidth = remember(scope) { PillWidth(scope) } + // A drag that starts upward from rest docks the strip for the whole gesture, back down + // included; one that starts downward cycles, as it always has. + var docking by remember { mutableStateOf(false) } val label = deckLabel(deck, now, viewerId, strip) val nextLabel = stringResource(R.string.action_nextRequest) @@ -464,6 +475,11 @@ private fun DeckStack( .draggable( orientation = Orientation.Vertical, state = rememberDraggableState { delta -> + if (onDock != null && (docking || delta < 0f && drag.value <= 0f)) { + docking = true + onDock(delta) + return@rememberDraggableState + } scope.launch { val value = drag.value val next = if (!canCycle) { @@ -481,6 +497,11 @@ private fun DeckStack( } }, onDragStopped = { velocity -> + if (docking) { + docking = false + onDockReleased?.invoke() + return@draggable + } val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY if (canCycle && past) { // Hand over on release: the card that rose becomes the front and the 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 9396919933..fdf3f57cd4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -1,12 +1,18 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.expandVertically +import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.services.models.paymentrequest.unpaidShareOf import com.flipcash.app.messenger.internal.request.RequestDeckStrip import com.flipcash.app.messenger.internal.request.rememberDeckHostState import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -33,6 +39,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -59,6 +66,7 @@ import com.getcode.opencode.model.core.ID import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.PinnedMessageJump import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.flipcash.app.core.chat.ProfileAddress @@ -81,10 +89,16 @@ import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge import com.flipcash.app.messenger.internal.screens.components.GatedLandingRow import com.flipcash.app.messenger.internal.screens.components.JoinRequestsAnchor +import com.flipcash.app.messenger.internal.screens.components.PinListButton +import com.flipcash.app.messenger.internal.screens.components.PinnedMessageBar +import com.flipcash.app.messenger.internal.pins.PinBar +import com.flipcash.app.messenger.internal.pins.PinSlot +import com.flipcash.app.messenger.internal.pins.pinSlot import com.flipcash.app.messenger.internal.screens.components.ReturnToChatChip import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.BlurredContent import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction @@ -120,6 +134,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.flipcash.app.messenger.internal.request.RequestPaySheet import com.flipcash.app.messenger.internal.request.RequestPayViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MessengerScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -506,6 +521,32 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val requestPayToken by requestPay.token.collectAsStateWithLifecycle() LaunchedEffect(requestPay) { requestPay.routes.collect { route -> navigator.navigate(route) } } + // Requests win the space under the top bar: while the strip is out, pins move to the top bar's + // trailing slot; once the strip is the pill, the pill has that slot and the bar comes back. + val pinBar = state.pins.bar(state.pinBarIndex) + val pinPlace = pinSlot( + hasPins = pinBar != null, + deckRest = deckHosts.rest.host.takeIf { state.chatType == ChatType.GROUP && drawnDeck.isNotEmpty() }, + collapsing = deckHosts.collapsing, + ) + // The list is a sheet destination over the chat; a row tapped there comes back as a jump. + val openPins = { pinAfterUnpin: Long? -> + val chatId = state.chatId + if (chatId != null) keyboard.hideIfVisible { + navigator.navigateForResult(ChatStep.PinnedMessages(chatId, pinAfterUnpin)) { result -> + if (result is NavResultOrCanceled.ReturnValue) { + viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(result.value.messageId)) + } + } + } + } + // At the pin cap the list opens to make room, holding the refused message for the freed slot. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .collect { openPins(it.waiting.messageId) } + } + // The collapsed pill's last bounds, where the expanded deck puts its close button. var deckPill by remember { mutableStateOf(null) } @@ -531,6 +572,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { chatActionHandler = chatActionHandler, dispatch = viewModel::dispatchEvent, onConfirmRemove = moderation::confirmRemove, + actions = { + // Leaves the composition in the frame the slot goes: the pill takes the + // title's end at full width as the strip starts to collapse, and + // AnimatedVisibility keeps exiting content laid out for a few frames even + // with no exit transition. + if (pinPlace == PinSlot.TopBarButton) { + val shown = remember { MutableTransitionState(false) }.apply { targetState = true } + AnimatedVisibility(visibleState = shown, enter = fadeIn() + scaleIn()) { + PinListButton(onClick = { openPins(null) }) + } + } + }, trailing = { if (state.chatType == ChatType.GROUP) { RequestDeckPill( @@ -570,6 +623,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } }, onHeightChange = { deckHosts.stripPx = it.toFloat() }, + onDock = deckHosts::dragDock, + onDockReleased = deckHosts::releaseDock, ) } if (stripBehindBackdrop) { @@ -585,6 +640,39 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } } + // Held through the exit, so the bar doesn't empty as it slides away. + var lastPinBar by remember { mutableStateOf(pinBar) } + if (pinBar != null) lastPinBar = pinBar + // Slides down from the top bar and fades, as iOS's bar moves in from the top edge. + // The expand alongside keeps the transcript's top inset (this column's height) + // moving with the bar, so the oldest message never snaps under it. + AnimatedVisibility( + visible = pinPlace == PinSlot.Bar, + enter = expandVertically(expandFrom = Alignment.Top) + + slideInVertically { -it } + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + + slideOutVertically { -it } + fadeOut(), + ) { + lastPinBar?.let { bar -> + val shown = (bar as? PinBar.Showing)?.pin + val spoken = remember(shown) { shown?.let(viewModel::pinPreview) } + val quote by produceState(null, shown) { + value = shown?.let { viewModel.pinQuote(it) } + } + PinnedMessageBar( + bar = bar, + quote = quote.takeIf { shown != null }, + spoken = spoken, + onTap = { keyboard.hideIfVisible { viewModel.dispatchEvent(ChatViewModel.Event.PinBarTapped) } }, + onOpenList = { openPins(null) }, + hazeState = hazeState, + modifier = Modifier + // Edges line up with the back button and the top bar's pin button. + .padding(horizontal = AppBarDefaults.ControlInset) + .padding(top = CodeTheme.dimens.grid.x2), + ) + } + } // Hold the last list through the exit, so the card doesn't empty as it slides away. var lastRequests by remember { mutableStateOf(state.joinRequests) } if (state.joinRequests.isNotEmpty()) lastRequests = state.joinRequests 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 d689e4ff9c..79f69c0147 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth @@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.PushPin import com.flipcash.app.messenger.internal.screens.openMessageShare import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -90,6 +92,8 @@ internal fun ChatTopBar( /** Asks the host to confirm a removal: chat, member, first name, group title. Supplied by the screen so the bar needs no Hilt. */ onConfirmRemove: (ChatId, ID, String, String) -> Unit = { _, _, _, _ -> }, trailing: @Composable () -> Unit = {}, + /** Controls in the bar's end slot, which sit the same distance from the edge as back does. */ + actions: @Composable RowScope.() -> Unit = {}, ) { val bgColor = CodeTheme.colors.background val statusBars = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -118,7 +122,7 @@ internal fun ChatTopBar( label = "chat top bar", ) { target -> when (target) { - TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler, trailing) + TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler, trailing, actions) TopBarMode.Editing -> EditingBar(dispatch) is TopBarMode.Selecting -> { val sender = target.selection.sender @@ -130,6 +134,7 @@ internal fun ChatTopBar( } MessageSelectionBar( selection = target.selection, + pinned = target.selection.messageId in state.pins.pinnedIds, keyboard = keyboard, dispatch = dispatch, // Resolved here rather than in the bar: reporting is its own top-level @@ -209,8 +214,10 @@ private fun ConversationTitleBar( state: ChatViewModel.State, chatActionHandler: ChatActionHandler, trailing: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, ) { AppBarWithTitle( + rightContents = actions, leftIcon = { // The avatar sits just past back and opens the profile, so a thumb that missed the // arrow landed in the dead strip before it or on the avatar. Everything before the @@ -314,6 +321,8 @@ private fun ConversationTitleBar( @Composable private fun MessageSelectionBar( selection: ChatListItem.ContentBubble, + /** Whether the selected message is pinned, so the pin action unpins it instead. */ + pinned: Boolean, keyboard: KeyboardController, dispatch: (ChatViewModel.Event) -> Unit, onShareMessage: () -> Unit, @@ -366,6 +375,26 @@ private fun MessageSelectionBar( ) ) } + // Granted by can_pin on a message that can be pinned; whether it pins or unpins is the + // chat's pinned ids', which the capability doesn't carry. + if (MessageCapability.Pin in capabilities) { + add( + MessageAction( + label = stringResource(if (pinned) R.string.action_unpinMessage else R.string.action_pinMessage), + icon = Icons.Filled.PushPin, + testTag = if (pinned) "action_unpin_message" else "action_pin_message", + onClick = { + dispatch( + if (pinned) { + ChatViewModel.Event.UnpinMessage(selection.messageId) + } else { + ChatViewModel.Event.PinMessage(selection.messageId, body) + } + ) + }, + ) + ) + } if (body != null && MessageCapability.Edit in capabilities) { add( MessageAction( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinBar.kt new file mode 100644 index 0000000000..708f97f8da --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinBar.kt @@ -0,0 +1,232 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.pins.PinBar +import com.flipcash.app.messenger.internal.pins.PinSegments +import com.flipcash.app.messenger.internal.pins.segments +import com.flipcash.features.messenger.R +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.ui.ChatQuoteContent +import com.flipcash.shared.chat.ui.ChatQuoteRule +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.CircularIconButton +import com.getcode.ui.components.chat.ChatInputDefaults +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur + +/** + * The bar under the top bar that quotes a pinned message (C1), drawn like the composer's reply + * strip: the author's rule, their name and one line of the message. With several pins the rule + * splits into one segment per pin, the shown one lit. A tap on the body jumps to the pin it shows + * and steps the bar to the next older one; the list button opens every pin. + * + * Keeps its own glass, like [JoinRequestsAnchor] beside it; only the quote comes from the strip. + * + * @param quote the shown pin as a quote, or null while the list is on its way. + * @param spoken the shown pin's one line for screen readers, which no longer get a title. + */ +@Composable +internal fun PinnedMessageBar( + bar: PinBar, + quote: ChatQuote?, + spoken: String?, + onTap: () -> Unit, + onOpenList: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val shape = CodeTheme.shapes.medium + val ground = if (hazeState != null) { + Modifier + .hazeBlur(HazeInput.Sources(hazeState), chatGlassMaterial()) + .background(ChatInputDefaults.ContainerColor) + } else { + Modifier.background(Color.White.copy(alpha = 0.1f)) + } + + val showing = bar as? PinBar.Showing + val description = if (quote != null) { + stringResource(R.string.cd_pinnedMessageFrom, quote.authorName, spoken.orEmpty()) + } else { + stringResource(R.string.title_pinnedMessage) + } + + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .then(ground) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .heightIn(min = PinBarMinHeight) + .height(IntrinsicSize.Min) + .testTag("pin_bar"), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable(enabled = showing != null, onClick = onTap) + .clearAndSetSemantics { + contentDescription = description + role = Role.Button + if (showing != null) onClick { onTap(); true } + } + // Inset like iOS's bar, so the rule sits off the glass edge; the tap target stays full. + .padding( + start = CodeTheme.dimens.staticGrid.x3, + top = CodeTheme.dimens.staticGrid.x2, + bottom = CodeTheme.dimens.staticGrid.x2, + ), + contentAlignment = Alignment.CenterStart, + ) { + val rule: @Composable (Color) -> Unit = { color -> + val segments = showing?.segments() + if (segments != null) SegmentedRule(color = color, segments = segments) else ChatQuoteRule(color) + } + if (quote != null) { + // iOS's bar: 10pt from the rule to the text, and no padding of the quote's own. + ChatQuoteContent( + quote = quote, + rule = rule, + gap = 10.dp, + textVerticalPadding = 0.dp, + ) + } else { + // Nothing to quote yet: the rule and a skeleton line hold the bar's place. + Row( + modifier = Modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + ChatQuoteRule(CodeTheme.colors.tertiary) + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(CodeTheme.dimens.staticGrid.x2) + .clip(CircleShape) + .background(CodeTheme.colors.textSecondary.copy(alpha = 0.3f)), + ) + } + } + } + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x10) + .clickable(onClick = onOpenList) + .testTag("action_open_pinned_list"), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_list_bullet_pin), + contentDescription = stringResource(R.string.cd_openPinnedMessages), + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) + } + } +} + +/** + * The quote's rule split into one segment per visible pin, stacked bottom up from the latest, the + * one the bar shows lit in the author's colour and the rest dimmed. A window two segments tall + * clips the track, which slides to keep the lit segment in view as taps step to older pins. + */ +@Composable +private fun SegmentedRule(color: Color, segments: PinSegments) { + val step = SegmentHeight + SegmentGap + // The track hangs from the window's bottom edge; scrolling up the track moves it down. + val scroll by animateDpAsState(targetValue = step * (segments.lowest - 1), label = "pin segments scroll") + Box( + modifier = Modifier + .size(width = SegmentWidth, height = SegmentWindowHeight) + .clipToBounds(), + contentAlignment = Alignment.BottomStart, + ) { + Column( + modifier = Modifier + .wrapContentHeight(align = Alignment.Bottom, unbounded = true) + .offset { IntOffset(0, scroll.roundToPx()) }, + verticalArrangement = Arrangement.spacedBy(SegmentGap), + ) { + for (segment in segments.count downTo 1) { + val lit by animateColorAsState( + targetValue = if (segment == segments.lit) color else color.copy(alpha = 0.3f), + label = "pin segment colour", + ) + Box( + modifier = Modifier + .size(width = SegmentWidth, height = SegmentHeight) + .background(lit), + ) + } + } + } +} + +/** + * The top bar's trailing list button (C6): stands in for the bar while the request strip holds the + * space under the top bar, and opens the list. + */ +@Composable +internal fun PinListButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + CircularIconButton( + modifier = modifier, + onClick = onClick, + testTag = "action_open_pinned_list_top_bar", + ) { size -> + Icon( + painter = painterResource(R.drawable.ic_list_bullet_pin), + contentDescription = stringResource(R.string.cd_openPinnedMessages), + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } +} + +// The join-requests anchor's height, which the bar matches so the two read as one family. +private val PinBarMinHeight = 50.dp +// Figma Cycling variant (node 13016:87): two segments fit the window, the rest scroll. +private val SegmentWidth = 3.dp +private val SegmentHeight = 16.dp +private val SegmentGap = 2.dp +private val SegmentWindowHeight = 34.dp diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheet.kt new file mode 100644 index 0000000000..7842f32626 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheet.kt @@ -0,0 +1,324 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.CircularIconButtonDefaults +import com.getcode.ui.core.expandedTouchTarget +import com.getcode.ui.components.AppBarWithTitle +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.material3.ripple +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import com.flipcash.shared.chat.ui.bubbleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.getcode.ui.components.BottomScrollEdge +import com.getcode.ui.components.TopScrollEdge +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.time.toJavaInstant + +/** + * Every visible pin, newest first (L1). Each row is the sender, the message drawn as its transcript + * bubble, and who pinned it when; a tap closes the list and jumps to the message. Unpin shows only + * with `can_pin` (L1c without). [full] is set when the list opened because the chat is at its pin + * cap (L1b): a header asks for an unpin, never naming the cap. + * + * @param fullQuote the message waiting on a slot, quoted in the header; null quotes nothing. + */ +@Composable +internal fun PinnedMessagesSheet( + rows: List, + canUnpin: Boolean, + full: Boolean, + fullQuote: String?, + onOpen: (Long) -> Unit, + onUnpin: (Long) -> Unit, + onClose: () -> Unit, +) { + val listState = rememberLazyListState() + AllowSheetExpansionWhenScrollable(listState) + val hazeState = rememberHazeState() + val density = LocalDensity.current + // The header's height, so the first row starts below it and the top blur covers it. + var headerHeight by remember { mutableStateOf(0.dp) } + val navigationBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + // As iOS's sheet: the title stays put while rows scroll under it, blurring in at the top and + // out at the bottom. + Box(modifier = Modifier.fillMaxWidth().testTag("pinned_messages_sheet")) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .hazeSource(hazeState), + contentPadding = PaddingValues( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + top = headerHeight, + bottom = navigationBar + CodeTheme.dimens.grid.x4, + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + items(rows, key = { it.pin.messageId }) { row -> + PinnedMessageRow( + modifier = Modifier.animateItem(), + row = row, + canUnpin = canUnpin, + onOpen = { onOpen(row.pin.messageId) }, + onUnpin = { onUnpin(row.pin.messageId) }, + ) + } + } + + TopScrollEdge( + hazeState = hazeState, + listState = listState, + height = headerHeight, + modifier = Modifier.align(Alignment.TopCenter), + ) + BottomScrollEdge( + hazeState = hazeState, + listState = listState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .onSizeChanged { headerHeight = with(density) { it.height.toDp() } } + .padding(bottom = CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + // The reactors sheet's header: a centred title and the bar's close at the end, frosted + // as the invite sheet's is, since rows scroll under it. + AppBarWithTitle( + title = stringResource(R.string.title_pinnedMessages), + titleAlignment = Alignment.CenterHorizontally, + endContent = { + val closeLabel = stringResource(R.string.cd_close) + // The bar's close is drawn smaller than 48dp; grow its touch area to that. + val reach = ((48.dp - CircularIconButtonDefaults.ButtonSize) / 2).coerceAtLeast(0.dp) + AppBarDefaults.Close( + // The shared close draws its icon with no description. + modifier = Modifier.expandedTouchTarget(PaddingValues(reach), onTap = onClose).semantics { + contentDescription = closeLabel + role = Role.Button + }, + hazeState = hazeState, + onClick = onClose, + ) + }, + ) + if (full) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .testTag("pinned_messages_full"), + text = fullQuote?.let { stringResource(R.string.label_pinsFull, it) } + ?: stringResource(R.string.label_pinsFullUntitled), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } +} + +/** + * One pinned message. Only the bubble (jump to the message) and Unpin take taps: the avatar, the + * name, the pinned-by line and the space around them do nothing, as on iOS. + */ +@Composable +internal fun PinnedMessageRow( + row: PinnedRow, + canUnpin: Boolean, + onOpen: () -> Unit, + onUnpin: () -> Unit, + modifier: Modifier = Modifier, +) { + val avatarSize = CodeTheme.dimens.staticGrid.x5 + val gutter = CodeTheme.dimens.grid.x1 + val jumpLabel = stringResource(R.string.action_jumpToMessage) + Column( + modifier = modifier + .fillMaxWidth() + .testTag("pinned_message_${row.pin.messageId}"), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + modifier = Modifier.padding(start = avatarSize + gutter), + text = row.sender.displayName, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(gutter), + ) { + ContactAvatar( + image = row.sender.picture, + displayName = row.sender.initialsName.orEmpty(), + access = BlobAccessContext.profile(row.sender.userId), + modifier = Modifier + .size(avatarSize) + .clip(CircleShape) + .clearAndSetSemantics { }, + ) + // ContentBubble fills the row but puts [modifier] on the bubble itself, so the jump + // covers the bubble and not the empty width beside it. + Box(Modifier.weight(1f)) { + ContentBubble( + item = row.bubble, + position = BubblePosition.Solo, + interactive = false, + leading = true, + modifier = Modifier + .clip(bubbleShape(BubblePosition.Solo, row.bubble.isFromSelf)) + .jumpTarget(onOpen) + .clearAndSetSemantics { + testTag = "pinned_bubble_${row.pin.messageId}" + row.spoken?.let { contentDescription = it } + role = Role.Button + onClick(label = jumpLabel) { onOpen(); true } + }, + ) + } + } + Row( + modifier = Modifier.padding(start = avatarSize + gutter), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.label_pinnedBy, row.pinnedByName, pinnedDate(row)), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + if (canUnpin) { + Spacer(Modifier.size(CodeTheme.dimens.grid.x2)) + // 48dp to touch, laid out at that height so it sits below the bubble, not over it. + Box( + modifier = Modifier + .minimumInteractiveComponentSize() + .clickable(role = Role.Button, onClick = onUnpin) + .testTag("action_unpin_${row.pin.messageId}"), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.action_unpinMessage), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + ) + } + } + } + } +} + +@Composable +private fun pinnedDate(row: PinnedRow): String { + val formatter = remember { + DateTimeFormatter.ofPattern( + android.text.format.DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd") + ) + } + return remember(row.pin.pinnedAt) { + formatter.format(row.pin.pinnedAt.toJavaInstant().atZone(ZoneId.systemDefault())) + } +} + +/** + * Takes a tap anywhere on the bubble before its own links and buttons see it, so the whole bubble + * is one jump. Only the down and the up are consumed: a drag still scrolls the list, and moving + * past touch slop drops the press. + */ +private fun Modifier.jumpTarget(onClick: () -> Unit): Modifier = composed { + val interactions = remember { MutableInteractionSource() } + val currentOnClick by rememberUpdatedState(onClick) + this + .indication(interactions, ripple()) + .pointerInput(interactions) { + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + down.consume() + val press = PressInteraction.Press(down.position) + interactions.tryEmit(press) + val slop = viewConfiguration.touchSlop + var released = false + while (true) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes + .firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + released = !change.isOutOfBounds(size, extendedTouchPadding) + break + } + if ((change.position - down.position).getDistance() > slop) break + } + if (released) { + interactions.tryEmit(PressInteraction.Release(press)) + currentOnClick() + } else { + interactions.tryEmit(PressInteraction.Cancel(press)) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/res/drawable/ic_list_bullet_pin.xml b/apps/flipcash/features/messenger/src/main/res/drawable/ic_list_bullet_pin.xml new file mode 100644 index 0000000000..dfd75632f4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/res/drawable/ic_list_bullet_pin.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index 8c785cc0ab..d91c0af0a3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -75,6 +75,7 @@ class ChatEventStallTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } private val contactCoordinator = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 765ebfff99..b1051ff9d1 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -85,6 +85,7 @@ class ChatGroupAnalyticsTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) coEvery { sampleChatters(any()) } returns Result.failure(IllegalStateException("not sampled")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 8766b176aa..0fa10d4bf2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -86,6 +86,7 @@ class ChatGroupCashLinkTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) coEvery { sampleChatters(any()) } returns Result.failure(IllegalStateException("not sampled")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index ba4d6d6382..21861f5879 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -59,6 +59,7 @@ class ChatMentionPickerTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) coEvery { sampleChatters(any()) } returns Result.failure(IllegalStateException("not sampled")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 063432f1fc..63e511c7ab 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -62,6 +62,7 @@ class ChatOpenTranscriptTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands unless a test emits one. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt index a4d184db23..d9372bc769 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt @@ -16,6 +16,7 @@ import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Rate import io.mockk.every +import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -45,6 +46,7 @@ class ChatPreviewsReadyTest { var mainCoroutineRule = MainCoroutineRule(StandardTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns emptyFlow() } private val tipPaymentDelegate = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt index 62119e785f..6c020ed16f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt @@ -61,6 +61,7 @@ class ChatReturnToOriginTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands unless a test emits one. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 63d22d5b09..f29fbd3c83 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -75,6 +75,7 @@ class ChatSendFailureAnalyticsTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } private val contactCoordinator = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt index 84e8028f19..87ba4ea81f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt @@ -70,6 +70,7 @@ class ChatSilenceLiftedToastTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands unless a test emits one. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt index b26e464431..88eb63355c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -58,6 +58,7 @@ class ChatTypingGateTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands; a relaxed Flow completes empty and fails the open's first { }. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/pins/ChatPinsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/pins/ChatPinsTest.kt new file mode 100644 index 0000000000..efac4c4d9c --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/pins/ChatPinsTest.kt @@ -0,0 +1,230 @@ +package com.flipcash.app.messenger.internal.pins + +import com.flipcash.app.messenger.internal.request.DeckPlacement +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Instant + +class ChatPinsTest { + + private val alex = listOf(1) + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun pin(id: Long, deleted: Boolean = false) = Pin( + messageId = id, + pinnedBy = alex, + pinnedAt = sentAt, + message = ChatMessage( + messageId = id, + senderId = alex, + content = listOf( + if (deleted) MessageContent.Deleted(sentAt, alex) else MessageContent.Text("message $id"), + ), + timestamp = sentAt, + unreadSeq = 0, + eventSequence = id, + ), + ) + + // PinsChanged moves the stored summary; the list is read again only past the held version. + @Test + fun `a summary newer than the held list is stale`() { + val held = ChatPins().read(listOf(pin(3)), readVersion = 4) + + assertTrue(held.isStale(5)) + assertFalse(held.isStale(4)) + assertFalse(held.isStale(3)) + } + + // Chat feeds leave pin_summary unset; that says nothing about pins. + @Test + fun `a missing summary never reads as stale or clears pins`() { + val held = ChatPins().read(listOf(pin(3)), readVersion = 4) + + assertFalse(held.isStale(null)) + assertEquals(listOf(3L), held.visible.map { it.messageId }) + } + + @Test + fun `before the first read any summary is stale`() { + assertTrue(ChatPins().isStale(0)) + } + + // Two reads racing: the one made at an older version lands last and must not win. + @Test + fun `a read at an older version doesn't replace a newer one`() { + val newer = ChatPins().read(listOf(pin(3), pin(2)), readVersion = 6) + + val after = newer.read(listOf(pin(2)), readVersion = 5) + + assertEquals(6L, after.version) + assertEquals(listOf(3L, 2L), after.pins.map { it.messageId }) + } + + @Test + fun `a read at a newer version replaces the list, emptying it included`() { + val held = ChatPins().read(listOf(pin(3)), readVersion = 4) + + val after = held.read(emptyList(), readVersion = 5) + + assertEquals(emptyList(), after.pins) + assertNull(after.bar(0)) + } + + // Deleted pinned messages stay pinned server-side; the client hides them. + @Test + fun `the bar skips a deleted latest pin for the next`() { + val pins = ChatPins().read(listOf(pin(5, deleted = true), pin(3), pin(1)), readVersion = 1) + + val bar = pins.bar(0) as PinBar.Showing + + assertEquals(3L, bar.pin.messageId) + assertEquals(1, bar.position) + assertEquals(2, bar.of) + } + + @Test + fun `a deleted pin still counts as pinned`() { + val pins = ChatPins().read(listOf(pin(5, deleted = true), pin(3)), readVersion = 1) + + assertTrue(5L in pins.pinnedIds) + } + + // A delete moves no pin version, so the list isn't read again; the stored tombstone hides it. + @Test + fun `a pinned message deleted after the read hides from its stored copy`() { + val read = ChatPins().read(listOf(pin(5), pin(3)), readVersion = 1) + + val pins = read.copy(stored = mapOf(5L to pin(5, deleted = true).message)) + + assertEquals(listOf(3L), pins.visible.map { it.messageId }) + assertEquals(1, (pins.bar(0) as PinBar.Showing).of) + assertTrue(5L in pins.pinnedIds) + } + + @Test + fun `a later read keeps the stored copies`() { + val pins = ChatPins() + .read(listOf(pin(5), pin(3)), readVersion = 1) + .copy(stored = mapOf(5L to pin(5, deleted = true).message)) + .read(listOf(pin(5), pin(3)), readVersion = 2) + + assertEquals(listOf(3L), pins.visible.map { it.messageId }) + } + + @Test + fun `only deleted pins hide the bar`() { + val pins = ChatPins().read(listOf(pin(5, deleted = true)), readVersion = 1) + + assertNull(pins.bar(0)) + } + + @Test + fun `a tap steps to the next older pin and wraps to the newest`() { + val pins = ChatPins().read(listOf(pin(5), pin(3), pin(1)), readVersion = 1) + + assertEquals(1, pins.stepAfter(0)) + assertEquals(2, pins.stepAfter(1)) + assertEquals(0, pins.stepAfter(2)) + } + + @Test + fun `the bar waits on the list when the summary says there are pins`() { + assertEquals(PinBar.Fetching, ChatPins(summaryCount = 2).bar(0)) + assertNull(ChatPins(summaryCount = 0).bar(0)) + } + + // C1: no requests drawn, the bar is under the top bar. + @Test + fun `without a request deck pins take the bar`() { + assertEquals(PinSlot.Bar, pinSlot(hasPins = true, deckRest = null)) + } + + // C6: the strip holds the space under the top bar, so pins move to its trailing slot. + @Test + fun `while the strip is out pins are a top bar button`() { + assertEquals(PinSlot.TopBarButton, pinSlot(hasPins = true, deckRest = DeckPlacement.Strip)) + } + + // C6b and C7b: scrolled or swiped into the pill, the pill has the trailing slot and the bar returns. + @Test + fun `once the strip is the pill pins take the bar`() { + assertEquals(PinSlot.Bar, pinSlot(hasPins = true, deckRest = DeckPlacement.Pill)) + } + + // C7a: the pill grows into the trailing slot as the strip collapses, so the button leaves first. + @Test + fun `a strip partway into the pill takes the button away and holds the bar back`() { + assertEquals(PinSlot.None, pinSlot(hasPins = true, deckRest = DeckPlacement.Strip, collapsing = true)) + } + + @Test + fun `nothing pinned takes no slot`() { + assertEquals(PinSlot.None, pinSlot(hasPins = false, deckRest = DeckPlacement.Strip)) + assertEquals(PinSlot.None, pinSlot(hasPins = false, deckRest = null)) + } + + @Test + fun `the list is done when its last visible pin leaves`() { + assertTrue(ChatPins().read(emptyList(), readVersion = 5).pinnedListDone(pinAfterUnpin = null)) + val tombstonesOnly = ChatPins().read(listOf(pin(3, deleted = true)), readVersion = 5) + assertTrue(tombstonesOnly.pinnedListDone(pinAfterUnpin = null)) + } + + @Test + fun `the list stays while pins remain`() { + assertFalse(ChatPins().read(listOf(pin(3)), readVersion = 5).pinnedListDone(pinAfterUnpin = null)) + } + + // L1b: an unpin frees the slot, the waiting message lands, and only then does the list close. + @Test + fun `a list opened full is done once the waiting message is pinned`() { + assertFalse(ChatPins().read(emptyList(), readVersion = 5).pinnedListDone(pinAfterUnpin = 9)) + assertFalse(ChatPins().read(listOf(pin(3)), readVersion = 5).pinnedListDone(pinAfterUnpin = 9)) + assertTrue(ChatPins().read(listOf(pin(3), pin(9)), readVersion = 6).pinnedListDone(pinAfterUnpin = 9)) + } + + // Segments stack bottom up from the latest pin; the two-segment window scrolls with the lit one. + @Test + fun `one pin keeps the single rule`() { + assertNull(showing(position = 1, of = 1).segments()) + } + + @Test + fun `segments for each step through 2, 3 and 5 pins`() { + // of to, per position: (visible bottom first, lit) + val expected = mapOf( + 2 to listOf(1..2 to 1, 1..2 to 2), + 3 to listOf(1..2 to 1, 1..2 to 2, 2..3 to 3), + 5 to listOf(1..2 to 1, 1..2 to 2, 2..3 to 3, 3..4 to 4, 4..5 to 5), + ) + for ((of, steps) in expected) { + steps.forEachIndexed { index, (visible, lit) -> + val segments = showing(position = index + 1, of = of).segments()!! + assertEquals("$of pins, step ${index + 1}: visible", visible, segments.visible) + assertEquals("$of pins, step ${index + 1}: lit", lit, segments.lit) + assertEquals(of, segments.count) + } + } + } + + @Test + fun `tapping steps the lit segment up and wraps back to the latest`() { + val pins = ChatPins().read((5 downTo 1).map { pin(it.toLong()) }, readVersion = 1) + var index = 0 + val lit = (1..6).map { + val segments = (pins.bar(index) as PinBar.Showing).segments()!! + index = pins.stepAfter(index) + segments.lit + } + assertEquals(listOf(1, 2, 3, 4, 5, 1), lit) + } + + private fun showing(position: Int, of: Int) = PinBar.Showing(pin = pin(position.toLong()), position = position, of = of) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckDockTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckDockTest.kt new file mode 100644 index 0000000000..2fcb8e7231 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckDockTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.app.messenger.internal.pins.PinSlot +import com.flipcash.app.messenger.internal.pins.pinSlot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** C7: a swipe up on the strip docks it into the pill without a scroll, through the collapse a scroll drives. */ +class DeckDockTest { + + private val dinner = listOf(1) + private val sunday = listOf(2) + + private fun strip() = DeckHostState(setOf(dinner)).apply { stripPx = 100f } + + @Test + fun `a swipe up partway collapses without docking`() { + val deck = strip() + + deck.dragDock(-40f) + + assertTrue(deck.collapsing) + assertEquals(DeckPlacement.Strip, deck.rest.host) + } + + @Test + fun `a swipe back down undoes the collapse`() { + val deck = strip() + + deck.dragDock(-40f) + deck.dragDock(60f) + + assertFalse(deck.collapsing) + assertEquals(DeckPlacement.Strip, deck.rest.host) + } + + // C7a: mid-swipe the pill is growing into the trailing slot, so the pin button is already gone. + @Test + fun `a swipe partway up takes the pin button out of the top bar`() { + val deck = strip() + + deck.dragDock(-30f) + + assertEquals( + PinSlot.None, + pinSlot(hasPins = true, deckRest = deck.rest.host, collapsing = deck.collapsing), + ) + } + + // C7b: docked, the pill holds the trailing slot and the pin bar comes back. + @Test + fun `a swipe the strip's full height docks it and gives pins the bar`() { + val deck = strip() + + deck.dragDock(-100f) + + assertEquals(DeckPlacement.Pill, deck.rest.host) + assertEquals(PinSlot.Bar, pinSlot(hasPins = true, deckRest = deck.rest.host)) + } + + @Test + fun `the dock holds while the same requests are owed`() { + val deck = strip() + deck.dragDock(-100f) + + deck.owing(setOf(dinner), hasDeck = true) + + assertEquals(DeckPlacement.Pill, deck.rest.host) + } + + @Test + fun `a new request brings the docked strip back`() { + val deck = strip() + deck.dragDock(-100f) + + deck.owing(setOf(dinner, sunday), hasDeck = true) + + assertEquals(DeckPlacement.Strip, deck.rest.host) + assertEquals(PinSlot.TopBarButton, pinSlot(hasPins = true, deckRest = deck.rest.host)) + } + + @Test + fun `nothing docks while the deck rests as the pill`() { + val deck = DeckHostState(emptySet()).apply { stripPx = 100f } + + deck.dragDock(-100f) + + assertFalse(deck.collapsing) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessageRowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessageRowTest.kt new file mode 100644 index 0000000000..8033ba7f31 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessageRowTest.kt @@ -0,0 +1,160 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity +import com.getcode.theme.DesignSystem +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Instant + +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "en") +class PinnedMessageRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val alex = listOf(1) + private val sentAt = Instant.fromEpochSeconds(1_000) + private val content = MessageContent.Text("see https://flipcash.com") + + private val row = PinnedRow( + pin = Pin( + messageId = 7, + pinnedBy = alex, + pinnedAt = sentAt, + message = ChatMessage( + messageId = 7, + senderId = alex, + content = listOf(content), + timestamp = sentAt, + unreadSeq = 0, + eventSequence = 7, + ), + ), + bubble = ChatListItem.ContentBubble( + messageId = 7, + contentIndex = 0, + content = content, + isFromSelf = false, + timestamp = sentAt, + ), + sender = SenderIdentity(userId = alex, displayName = "Alex", picture = null), + pinnedByName = "You", + spoken = "see https://flipcash.com", + ) + + // The merged tree, as accessibility reads it. The bubble's own text and link still sit in the + // unmerged tree under the cover's clearAndSetSemantics. + private val clickable = SemanticsMatcher.keyIsDefined(SemanticsActions.OnClick) + + private class RecordingUriHandler : UriHandler { + val opened = mutableListOf() + override fun openUri(uri: String) { + opened += uri + } + } + + @Test + fun `only the bubble and Unpin take a click`() { + var opened = 0 + var unpinned = 0 + composeTestRule.setContent { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = true, onOpen = { opened++ }, onUnpin = { unpinned++ }) + } + } + + val clicks = composeTestRule.onAllNodes(clickable) + .fetchSemanticsNodes() + .map { it.config.getOrNull(SemanticsProperties.TestTag) } + assertEquals(listOf("pinned_bubble_7", "action_unpin_7"), clicks) + + composeTestRule.onNodeWithTag("pinned_bubble_7").performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.onNodeWithTag("action_unpin_7").performSemanticsAction(SemanticsActions.OnClick) + assertEquals(1, opened) + assertEquals(1, unpinned) + } + + @Test + fun `the bubble is labelled as the jump and reads its message`() { + composeTestRule.setContent { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = true, onOpen = {}, onUnpin = {}) + } + } + + val bubble = composeTestRule.onNodeWithTag("pinned_bubble_7").fetchSemanticsNode() + assertEquals("Jump to message", bubble.config[SemanticsActions.OnClick].label) + assertEquals(listOf("see https://flipcash.com"), bubble.config[SemanticsProperties.ContentDescription]) + // The bubble's own text is not a second node: the cover speaks for it. + composeTestRule.onAllNodesWithText("see https://flipcash.com") + .fetchSemanticsNodes() + .let { assertEquals(0, it.size) } + } + + @Test + fun `a real tap fires only on the bubble and Unpin`() { + var opened = 0 + var unpinned = 0 + val uris = RecordingUriHandler() + composeTestRule.setContent { + CompositionLocalProvider(LocalUriHandler provides uris) { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = true, onOpen = { opened++ }, onUnpin = { unpinned++ }) + } + } + } + + composeTestRule.onNodeWithText("Alex").performTouchInput { click() } + composeTestRule.onNodeWithText("Pinned by You", substring = true).performTouchInput { click() } + assertEquals(0, opened) + assertEquals(0, unpinned) + + // Off-centre, near the bubble's corner and Unpin's edge. + composeTestRule.onNodeWithTag("pinned_bubble_7").performTouchInput { click(topLeft + Offset(4f, 4f)) } + composeTestRule.onNodeWithTag("action_unpin_7").performTouchInput { click(centerRight - Offset(2f, 0f)) } + assertEquals(1, opened) + assertEquals(1, unpinned) + + // On the link inside the bubble: the jump, not the link. + composeTestRule.onNodeWithTag("pinned_bubble_7").performTouchInput { click(center) } + assertEquals(2, opened) + assertEquals(emptyList(), uris.opened) + } + + @Test + fun `without the right to unpin, the bubble is the only click`() { + composeTestRule.setContent { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = false, onOpen = {}, onUnpin = {}) + } + } + + val clicks = composeTestRule.onAllNodes(clickable).fetchSemanticsNodes() + assertEquals(1, clicks.size) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheetTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheetTest.kt new file mode 100644 index 0000000000..729f1148a4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheetTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.click +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity +import com.getcode.theme.DesignSystem +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Instant + +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "en") +class PinnedMessagesSheetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val alex = listOf(1) + private val sentAt = Instant.fromEpochSeconds(1_000) + private val content = MessageContent.Text("see https://flipcash.com") + + private val row = PinnedRow( + pin = Pin( + messageId = 7, + pinnedBy = alex, + pinnedAt = sentAt, + message = ChatMessage( + messageId = 7, + senderId = alex, + content = listOf(content), + timestamp = sentAt, + unreadSeq = 0, + eventSequence = 7, + ), + ), + bubble = ChatListItem.ContentBubble( + messageId = 7, + contentIndex = 0, + content = content, + isFromSelf = false, + timestamp = sentAt, + ), + sender = SenderIdentity(userId = alex, displayName = "Alex", picture = null), + pinnedByName = "You", + spoken = "see https://flipcash.com", + ) + + private fun showSheet(canUnpin: Boolean, full: Boolean, onClose: () -> Unit) { + composeTestRule.setContent { + DesignSystem { + PinnedMessagesSheet( + rows = listOf(row), + canUnpin = canUnpin, + full = full, + fullQuote = null, + onOpen = {}, + onUnpin = {}, + onClose = onClose, + ) + } + } + } + + private fun closeWorks(canUnpin: Boolean, full: Boolean) { + var closed = 0 + showSheet(canUnpin = canUnpin, full = full, onClose = { closed++ }) + + val close = composeTestRule.onNodeWithContentDescription("Close") + // Off-centre, near the button's corner. + close.performTouchInput { click(Offset(width * 0.8f, height * 0.25f)) } + assertEquals(1, closed) + // 23dp out from its centre on each side, just inside a 48dp target, still closes. + close.performTouchInput { + val reach = 23.dp.toPx() + listOf(Offset(-reach, 0f), Offset(reach, 0f), Offset(0f, -reach), Offset(0f, reach)) + .forEach { click(center + it) } + } + assertEquals(5, closed) + + // The title stays centred with the close beside it. + val root = composeTestRule.onRoot().getBoundsInRoot() + val title = composeTestRule.onNodeWithText("Pinned Messages").getBoundsInRoot() + assertEquals( + ((root.left + root.right) / 2).value, + ((title.left + title.right) / 2).value, + 1f, + ) + } + + @Test + fun `the close pops the sheet in the normal list`() = closeWorks(canUnpin = true, full = false) + + @Test + fun `the close pops the sheet in the read-only list`() = closeWorks(canUnpin = false, full = false) + + @Test + fun `the close pops the sheet when the chat is full`() = closeWorks(canUnpin = true, full = true) +} 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 60e124fbe1..637bcdb566 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 @@ -131,11 +131,12 @@ private fun ChatSummary.formatPreview( /** * The row's line for one piece of message content, or null when there is nothing worth previewing. + * Also the pin bar's one-line quote, which passes no attribution. * * [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( +fun MessageContent.previewText( sentBySelf: Boolean, senderName: String?, isGroup: Boolean, 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 14a74bf3a3..1853c429c8 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 @@ -67,9 +67,6 @@ fun ComposerReplyStrip( modifier: Modifier = Modifier, hazeState: HazeState? = null, ) { - val rule = quote.accent ?: CodeTheme.colors.tertiary - val name = quote.nameAccent ?: rule - val shape = CodeTheme.shapes.medium // The composer's material, not a brighter one: a tint of the background itself rather than of a // grey lifted off it. `clip` must precede `hazeBlur` to bound the blur to the rounded rect. @@ -101,34 +98,12 @@ fun ComposerReplyStrip( horizontalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.gap), verticalAlignment = Alignment.CenterVertically, ) { - // Square and flush to the card's leading edge; the card's own clip is what rounds it. - Box( - modifier = Modifier - .width(ComposerReplyStripDefaults.ruleWidth) - .fillMaxHeight() - .background(rule), + ChatQuoteContent( + quote = quote, + modifier = Modifier.weight(1f), + snippetMaxLines = ComposerReplyStripDefaults.textMaxLines, ) - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = ComposerReplyStripDefaults.textPadding), - ) { - Text( - text = quote.authorName, - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), - color = name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - QuoteLine(quote.snippet) - } - - // After the text and before the way out, centred on the row, as iOS lays it. Nothing for a - // redacted photo or any other kind of quote. - val photo = quote.snippet as? ChatQuoteSnippet.Photo - if (photo != null && !photo.redacted) QuotedPhotoThumbnail(photo) - // A disc rather than a bare ✕, in a hit target wider than the disc. Box( modifier = Modifier @@ -157,20 +132,87 @@ fun ComposerReplyStrip( } } +/** + * A quote's body as the reply strip draws it: the rule in the author's colour flush to the leading + * edge, their name over the snippet, then a photo's thumbnail. The caller owns the ground and what + * trails it, so the reply strip and the pin bar can share it on their own surfaces. + * + * Lays out as a row filling the caller's height; give it a parent with a bounded or intrinsic + * height so [rule] can fill it. + * + * @param rule what runs down the leading edge, given the author's colour. The reply strip's is a + * single bar; the pin bar splits it into one segment per pin. + * @param gap the space between [rule] and the text. + * @param textVerticalPadding the space above and below the text. The pin bar pads itself, so it + * passes none. + */ +@Composable +fun ChatQuoteContent( + quote: ChatQuote, + modifier: Modifier = Modifier, + snippetMaxLines: Int = 1, + rule: @Composable (Color) -> Unit = { color -> ChatQuoteRule(color) }, + gap: Dp = ComposerReplyStripDefaults.gap, + textVerticalPadding: Dp = ComposerReplyStripDefaults.textPadding, +) { + val ruleColor = quote.accent ?: CodeTheme.colors.tertiary + val name = quote.nameAccent ?: ruleColor + Row( + modifier = modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(gap), + verticalAlignment = Alignment.CenterVertically, + ) { + rule(ruleColor) + + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = textVerticalPadding), + ) { + if (quote.authorName.isNotEmpty()) { + Text( + text = quote.authorName, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + QuoteLine(quote.snippet, snippetMaxLines) + } + + // After the text, centred on the row, as iOS lays it. Nothing for a redacted photo or any + // other kind of quote. + val photo = quote.snippet as? ChatQuoteSnippet.Photo + if (photo != null && !photo.redacted) QuotedPhotoThumbnail(photo) + } +} + +/** The reply strip's rule: square, flush to the card's leading edge; the card's clip rounds it. */ +@Composable +fun ChatQuoteRule(color: Color, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .width(ComposerReplyStripDefaults.ruleWidth) + .fillMaxHeight() + .background(color), + ) +} + /** * 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) { +private fun QuoteLine(snippet: ChatQuoteSnippet, maxLines: Int) { val style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium) when (snippet) { is ChatQuoteSnippet.Text -> Text( text = labelInviteLinks(snippet.body, stringResource(R.string.label_inviteLinkPreview)), style = style, color = CodeTheme.colors.textMain, - maxLines = ComposerReplyStripDefaults.textMaxLines, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) @@ -182,7 +224,7 @@ private fun QuoteLine(snippet: ChatQuoteSnippet) { ), style = style, color = CodeTheme.colors.textMain, - maxLines = ComposerReplyStripDefaults.textMaxLines, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) 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 1d98ed7504..ab4e9b71c5 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 @@ -135,6 +135,8 @@ private val EDITED_MARKER_GAP = 6.dp * @param attention how strongly this bubble is currently being pointed at, 0f to 1f — the flash a * jump leaves on the message it landed on. A lambda because it is read while drawing: an animation * running through it repaints the bubble without recomposing it or the list carrying it. + * @param leading whether the bubble sits at the leading edge whoever sent it, as in a list of + * messages lifted out of the transcript; it keeps its own style either way. */ @Composable fun ContentBubble( @@ -142,6 +144,7 @@ fun ContentBubble( position: BubblePosition, modifier: Modifier = Modifier, interactive: Boolean = true, + leading: Boolean = false, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, @@ -192,7 +195,7 @@ fun ContentBubble( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (item.isFromSelf) Arrangement.End else Arrangement.Start, + horizontalArrangement = if (item.isFromSelf && !leading) Arrangement.End else Arrangement.Start, ) { // The card row of a split message, bare whatever the message around it carries -- a // text or a reply alike. The rows either side of it hold the prose. diff --git a/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt index ba5feae264..bb64081428 100644 --- a/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt @@ -61,6 +61,13 @@ object AppBarDefaults { internal val BarHeight = 56.dp internal val SlotPadding = 5.dp + /** + * How far the bar's leading and trailing controls sit from the screen edge, for content under + * the bar that should line up with them. + */ + val ControlInset: Dp + @Composable get() = CodeTheme.dimens.grid.x2 + SlotPadding + /** * How far a leading control's touch target can grow past it, for a start-aligned title: to the bar's * leading edge, top and bottom, and across the gap up to where the title starts. For use with