diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 3d8c3da0b4..8b512c3b7d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -425,6 +425,10 @@ class EventStreamDelegate @Inject constructor( // GetChat rather than waiting on a later update to correct it. metadataDataSource.updateTitle(chatId, metaUpdate.newTitle) } + is MetadataUpdate.DescriptionChanged -> { + // Same best-effort contract as TitleChanged above. Empty means cleared. + metadataDataSource.updateDescription(chatId, metaUpdate.newDescription) + } is MetadataUpdate.PictureChanged -> { // Same best-effort contract as TitleChanged above. metadataDataSource.updatePicture(chatId, metaUpdate.newPicture) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt index 719da56c1d..c4e12d9457 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt @@ -159,6 +159,24 @@ class ChatMetadataEditStreamTest { } } + @Test + fun `a description change from the stream is stored`() = runTest(testDispatchers.dispatcher) { + tornDown { + triggerCollection() + + chatUpdatesChannel.send( + ChatUpdate( + chatId = chatId, + metadataUpdates = listOf(MetadataUpdate.DescriptionChanged("New description")), + ), + ) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateDescription(chatId, "New description") } + } + } + @Test fun `a picture change from the stream is stored`() = runTest(testDispatchers.dispatcher) { tornDown { diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/42.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/42.json new file mode 100644 index 0000000000..cecd243c25 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/42.json @@ -0,0 +1,965 @@ +{ + "formatVersion": 1, + "database": { + "version": 42, + "identityHash": "a55b66c51aac810dc01a09f353d5cadb", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `description` TEXT, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + } + ], + "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, 'a55b66c51aac810dc01a09f353d5cadb')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index ce48e94ad3..6b086f2375 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -117,8 +117,9 @@ import com.getcode.utils.subByteArray // The chat_archive table. An AutoMigration rather than the fallbackToDestructiveMigration() // below, for the same reason as chat_draft: an archive set cannot be re-fetched. AutoMigration(from = 40, to = 41), // chat_archive table + AutoMigration(from = 41, to = 42), // chat_metadata.description (nullable) ], - version = 41, + version = 42, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 574b5d24f9..ce6242461e 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -89,6 +89,7 @@ interface ChatMetadataDao { "THEN :lastMessageId ELSE last_message_id END, " + "is_hidden = :isHidden, " + "title = :title, " + + "description = :description, " + "picture_json = :pictureJson, " + "rules_json = :rulesJson, " + "is_member = :isMember, " + @@ -102,6 +103,7 @@ interface ChatMetadataDao { lastMessageId: Long?, isHidden: Boolean, title: String?, + description: String?, pictureJson: MediaItem?, rulesJson: ChatRulesSerialized?, isMember: Boolean, @@ -202,6 +204,7 @@ interface ChatMetadataDao { lastMessageId = entity.lastMessageId, isHidden = entity.isHidden, title = entity.title, + description = entity.description, pictureJson = entity.pictureJson, rulesJson = entity.rulesJson, isMember = entity.isMember, @@ -311,6 +314,13 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET title = :title WHERE chat_id_hex = :chatIdHex") suspend fun updateTitle(chatIdHex: String, title: String) + /** + * Unconditional, for the same reason as [updateTitle]: `MetadataUpdate.DescriptionChanged` + * carries no version. Null clears the column; the caller maps the wire's empty string to it. + */ + @Query("UPDATE chat_metadata SET description = :description WHERE chat_id_hex = :chatIdHex") + suspend fun updateDescription(chatIdHex: String, description: String?) + /** Unconditional, for the same reason as [updateTitle]: `MetadataUpdate.PictureChanged` carries no version. */ @Query("UPDATE chat_metadata SET picture_json = :pictureJson WHERE chat_id_hex = :chatIdHex") suspend fun updatePicture(chatIdHex: String, pictureJson: MediaItem) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index f13055a3c4..1781c99b44 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -69,4 +69,7 @@ data class ChatMetadataEntity( // behaviourally for now; carried so a chat rebuilt from Room agrees with the network value. @ColumnInfo(name = "use_e2ee", defaultValue = "0") val useE2ee: Boolean = false, + // Group description, up to 160 characters; null when unset or on a DM. + @ColumnInfo(name = "description") + val description: String? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index efd55a45d3..c10c80d97e 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -53,6 +53,7 @@ class ChatMetadataDaoTest { isHidden: Boolean = false, analyticsCountedThrough: Long = 0, title: String? = null, + description: String? = null, pictureJson: MediaItem? = null, memberCount: Long = 0, rosterVersion: Long = 0, @@ -72,6 +73,7 @@ class ChatMetadataDaoTest { isHidden = isHidden, analyticsCountedThrough = analyticsCountedThrough, title = title, + description = description, pictureJson = pictureJson, memberCount = memberCount, rosterVersion = rosterVersion, @@ -205,6 +207,34 @@ class ChatMetadataDaoTest { assertEquals("New title", dao.getById(CHAT_HEX)?.title) } + /** Same unconditional contract as `updateTitle`, for `MetadataUpdate.DescriptionChanged`. */ + @Test + fun `updateDescription overwrites the description unconditionally`() = runTest { + dao.upsert(entity(chatType = "GROUP", description = "Old")) + + dao.updateDescription(CHAT_HEX, "New") + + assertEquals("New", dao.getById(CHAT_HEX)?.description) + } + + @Test + fun `updateDescription with null clears the description`() = runTest { + dao.upsert(entity(chatType = "GROUP", description = "Old")) + + dao.updateDescription(CHAT_HEX, null) + + assertEquals(null, dao.getById(CHAT_HEX)?.description) + } + + @Test + fun `upsert refreshes the description of a stored chat`() = runTest { + dao.upsert(entity(chatType = "GROUP", description = "Old")) + + dao.upsert(entity(chatType = "GROUP", description = "Fetched")) + + assertEquals("Fetched", dao.getById(CHAT_HEX)?.description) + } + /** Same unconditional contract as `updateTitle`, for `MetadataUpdate.PictureChanged`. */ @Test fun `updatePicture overwrites the picture unconditionally`() = runTest { diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index fdf71e5def..e8d9f3b01c 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -152,6 +152,14 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.updateTitle(mapper.chatIdHex(chatId), title) } + /** + * Applies a `MetadataUpdate.DescriptionChanged` for [chatId]. An empty [description] clears + * it. See ChatMetadataDao.updateDescription. + */ + suspend fun updateDescription(chatId: ChatId, description: String) { + db?.chatMetadataDao()?.updateDescription(mapper.chatIdHex(chatId), description.takeIf { it.isNotEmpty() }) + } + /** Applies a `MetadataUpdate.PictureChanged` for [chatId]. See ChatMetadataDao.updatePicture. */ suspend fun updatePicture(chatId: ChatId, picture: MediaItem) { db?.chatMetadataDao()?.updatePicture(mapper.chatIdHex(chatId), picture) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 212742203b..c0e8377e2a 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -87,6 +87,7 @@ class ChatEntityMapper @Inject constructor() { lastMessageId = metadata.lastMessage?.messageId, isHidden = metadata.isHidden, title = metadata.title, + description = metadata.description, pictureJson = metadata.picture, memberCount = metadata.rosterSummary.memberCount, rosterVersion = metadata.rosterSummary.version, @@ -117,6 +118,7 @@ class ChatEntityMapper @Inject constructor() { latestEventSequence = 0, isHidden = entity.isHidden, title = entity.title, + description = entity.description, picture = entity.pictureJson, rosterSummary = RosterSummary( memberCount = entity.memberCount, diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index ea4acf46e8..504bfe72c2 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -276,6 +276,15 @@ class ChatEntityMapperTest { assertEquals(true, restored.useE2ee) } + @Test + fun `group description survives the round trip through the row`() { + val entity = mapper.toEntity(groupMetadata().copy(description = "Weekly tips")) + assertEquals("Weekly tips", entity.description) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + assertEquals("Weekly tips", restored.description) + } + @Test fun `a chat with no creator and no e2ee round trips to null and false`() { val entity = mapper.toEntity(metadata(latestEventSequence = 0)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3e20e2977..7451f27c76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.3" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.6.0" -flipcash2-client-protocol = "0.16.0" +flipcash2-client-protocol = "0.17.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index d368878fda..a99a30cdde 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -46,6 +46,7 @@ class ChatMetadataMapper @Inject constructor( latestEventSequence = from.latestEventSequence, isHidden = from.isHidden, title = from.title.takeIf { it.isNotEmpty() }, + description = from.description.takeIf { it.isNotEmpty() }, picture = if (from.hasPicture()) from.picture.toMediaItem() else null, rosterSummary = from.rosterSummary.toRosterSummary(), rules = if (from.hasRules()) from.rules.toChatRules() else null, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index 6f1d002797..5df4d9c48b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -17,6 +17,7 @@ import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.DescriptionEdit import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.KeyEnvelope @@ -110,6 +111,7 @@ internal class ChatApi @Inject constructor( is StartChatParameters.Group -> requestBuilder.setPublicGroup( RpcChatService.StartChatRequest.PublicGroupChatParameters.newBuilder() .setTitle(parameters.title) + .apply { parameters.description?.let { setDescription(it) } } .apply { parameters.picture?.let { setPicture( @@ -123,6 +125,7 @@ internal class ChatApi @Inject constructor( is StartChatParameters.PrivateGroup -> requestBuilder.setPrivateGroup( RpcChatService.StartChatRequest.PrivateGroupChatParameters.newBuilder() .setTitle(parameters.title) + .apply { parameters.description?.let { setDescription(it) } } .apply { parameters.picture?.let { setPicture( @@ -194,6 +197,17 @@ internal class ChatApi @Inject constructor( .setValue(it) ) } + parameters.description?.let { edit -> + setDescription( + RpcChatService.EditChatRequest.Description.newBuilder() + .setValue( + when (edit) { + is DescriptionEdit.Set -> edit.value + DescriptionEdit.Clear -> "" + } + ) + ) + } parameters.picture?.let { setPicture( RpcChatService.EditChatRequest.Picture.newBuilder() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 858176069b..2af2f7acf8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -388,6 +388,8 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate( MetadataUpdate.ViewerStateChanged(viewerStateChanged.viewerState.toViewerState()) ChatModel.MetadataUpdate.KindCase.TITLE_CHANGED -> MetadataUpdate.TitleChanged(titleChanged.newTitle) + ChatModel.MetadataUpdate.KindCase.DESCRIPTION_CHANGED -> + MetadataUpdate.DescriptionChanged(descriptionChanged.newDescription) ChatModel.MetadataUpdate.KindCase.PICTURE_CHANGED -> MetadataUpdate.PictureChanged(pictureChanged.newPicture.toMediaItem()) else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) @@ -417,6 +419,7 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { latestEventSequence = latestEventSequence, isHidden = isHidden, title = title.takeIf { it.isNotEmpty() }, + description = description.takeIf { it.isNotEmpty() }, picture = if (hasPicture()) picture.toMediaItem() else null, rosterSummary = rosterSummary.toRosterSummary(), rules = if (hasRules()) rules.toChatRules() else null, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index 977f6d9dba..8cfa6f93ca 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -190,6 +190,8 @@ internal class ChatService @Inject constructor( RpcChatService.StartChatResponse.Result.DENIED -> Result.failure(StartChatError.Denied()) RpcChatService.StartChatResponse.Result.TITLE_MODERATED -> Result.failure(StartChatError.TitleModerated(response.flaggedCategory.toFlaggedCategory())) + RpcChatService.StartChatResponse.Result.DESCRIPTION_MODERATED -> + Result.failure(StartChatError.DescriptionModerated(response.flaggedCategory.toFlaggedCategory())) RpcChatService.StartChatResponse.Result.PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(StartChatError.PictureBlobNotAccepted()) RpcChatService.StartChatResponse.Result.INVALID_RULES -> Result.failure(StartChatError.InvalidRules()) RpcChatService.StartChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(StartChatError.RulesNotSatisfied()) @@ -218,6 +220,8 @@ internal class ChatService @Inject constructor( RpcChatService.EditChatResponse.Result.NOT_FOUND -> Result.failure(EditChatError.NotFound()) RpcChatService.EditChatResponse.Result.TITLE_MODERATED -> Result.failure(EditChatError.TitleModerated(response.flaggedCategory.toFlaggedCategory())) + RpcChatService.EditChatResponse.Result.DESCRIPTION_MODERATED -> + Result.failure(EditChatError.DescriptionModerated(response.flaggedCategory.toFlaggedCategory())) RpcChatService.EditChatResponse.Result.PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.PictureBlobNotAccepted()) RpcChatService.EditChatResponse.Result.UNRECOGNIZED -> Result.failure(EditChatError.Unrecognized()) else -> Result.failure(EditChatError.Other()) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index d08521e426..17838ba99c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -455,6 +455,7 @@ sealed class StartChatError( ): CodeServerError(message, cause) { class Denied : StartChatError("Denied") class TitleModerated(val category: ModerationResult.FlaggedCategory) : StartChatError("Title flagged: $category") + class DescriptionModerated(val category: ModerationResult.FlaggedCategory) : StartChatError("Description flagged: $category") class PictureBlobNotAccepted : StartChatError("Picture blob not accepted") class InvalidRules : StartChatError("Invalid rules") // The caller does not meet the rules they are setting on the chat being created (e.g. a @@ -472,6 +473,7 @@ sealed class EditChatError( class Denied : EditChatError("Denied") class NotFound : EditChatError("Not found") class TitleModerated(val category: ModerationResult.FlaggedCategory) : EditChatError("Title flagged: $category") + class DescriptionModerated(val category: ModerationResult.FlaggedCategory) : EditChatError("Description flagged: $category") class PictureBlobNotAccepted : EditChatError("Picture blob not accepted") class Unrecognized : EditChatError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EditChatError(message = cause?.message, cause = cause), NotifiableError diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index 8e1b166d5f..c408a5fca6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -13,6 +13,8 @@ data class ChatMetadata( val isHidden: Boolean = false, // Title for this chat. Only set for group chats. val title: String? = null, + // Description for this chat, up to 160 characters. Only set for group chats; null when unset. + val description: String? = null, // Picture for this chat. Only set for group chats. val picture: MediaItem? = null, // True roster size and staleness version. Server-authoritative; defaults to zero for diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt index af01fb1dec..dbb4f24cbf 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt @@ -11,4 +11,20 @@ data class EditChatParameters( // The already-uploaded-and-READY blob to use as the new picture. The client uploads only // the ORIGINAL rendition; the server derives the rest. Null leaves the picture unchanged. val picture: BlobId? = null, + // Null leaves the description unchanged; see [DescriptionEdit] for set vs clear. + val description: DescriptionEdit? = null, ) + +/** + * An edit to a group's description, up to 160 characters, moderated the same way as the title. + * Modeled as two cases rather than a nullable string because the wire distinguishes "leave it" + * (wrapper unset) from "clear it" (wrapper set, empty value), and an empty string alone could not + * carry both. + */ +sealed interface DescriptionEdit { + /** Replaces the description with [value]. */ + data class Set(val value: String) : DescriptionEdit + + /** Removes the description. */ + data object Clear : DescriptionEdit +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt index 857a815927..f6591f9712 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt @@ -10,6 +10,9 @@ sealed interface MetadataUpdate { // A group chat's title was edited. Best-effort delivery: applied as received, no ordering // guaranteed against other updates for the same chat. data class TitleChanged(val newTitle: String) : MetadataUpdate + // A group chat's description was edited; an empty string means it was cleared. Best-effort + // delivery, same caveat as TitleChanged. + data class DescriptionChanged(val newDescription: String) : MetadataUpdate // A group chat's picture was edited. Best-effort delivery, same caveat as TitleChanged. data class PictureChanged(val newPicture: MediaItem) : MetadataUpdate } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/StartChatParameters.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/StartChatParameters.kt index 94a32952b4..4e83eee82c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/StartChatParameters.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/StartChatParameters.kt @@ -11,6 +11,8 @@ sealed interface StartChatParameters { data class Group( /** Title for the chat. */ val title: String, + /** Description for the chat, up to 160 characters. Optional; null or empty sets none. */ + val description: String? = null, /** The blob holding the ORIGINAL picture the caller uploaded. Optional. */ val picture: BlobId? = null, /** @@ -30,6 +32,8 @@ sealed interface StartChatParameters { data class PrivateGroup( /** Title for the chat. */ val title: String, + /** Description for the chat, up to 160 characters. Optional; null or empty sets none. */ + val description: String? = null, /** The blob holding the ORIGINAL picture the caller uploaded. Optional. */ val picture: BlobId? = null, ) : StartChatParameters diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatMetadataExtensionTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatMetadataExtensionTest.kt index 508d6ad5a2..c823b6adc2 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatMetadataExtensionTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatMetadataExtensionTest.kt @@ -5,8 +5,10 @@ import com.codeinc.flipcash.gen.common.v1.Common import com.codeinc.flipcash.gen.profile.v1.Model as ProfileModel import com.google.protobuf.ByteString import com.google.protobuf.Timestamp +import com.flipcash.services.models.chat.MetadataUpdate import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertNull /** * The member id is what authorizes re-minting a profile picture's expired download URL, and the @@ -57,4 +59,49 @@ class ChatMetadataExtensionTest { assertEquals(ByteArray(16) { 3 }.toList(), result.members[0].userProfile.userId) } + + private fun groupMetadata(description: String): ChatModel.Metadata = + ChatModel.Metadata.newBuilder() + .setChatId( + Common.ChatId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(32) { 1 })) + ) + .setType(ChatModel.ChatType.GROUP) + .setLastActivity(Timestamp.newBuilder().setSeconds(2000)) + .setDescription(description) + .build() + + @Test + fun `metadata carries the description`() { + assertEquals("Weekly tips", groupMetadata("Weekly tips").toChatMetadata().description) + } + + @Test + fun `an empty description maps to null`() { + assertNull(groupMetadata("").toChatMetadata().description) + } + + @Test + fun `a description_changed update decodes to DescriptionChanged`() { + val update = ChatModel.MetadataUpdate.newBuilder() + .setDescriptionChanged( + ChatModel.MetadataUpdate.DescriptionChanged.newBuilder().setNewDescription("Fresh") + ) + .build() + + val result = update.toMetadataUpdate { it.toChatMetadata() } + + assertEquals(MetadataUpdate.DescriptionChanged("Fresh"), result) + } + + @Test + fun `a description_changed update with an empty value decodes as a clear`() { + val update = ChatModel.MetadataUpdate.newBuilder() + .setDescriptionChanged(ChatModel.MetadataUpdate.DescriptionChanged.getDefaultInstance()) + .build() + + val result = update.toMetadataUpdate { it.toChatMetadata() } + + assertEquals(MetadataUpdate.DescriptionChanged(""), result) + } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt index 11ab9829d8..595c533bf9 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/DomainModelsTest.kt @@ -151,6 +151,12 @@ class DomainModelsTest { assertEquals("New title", update.newTitle) } + @Test + fun `MetadataUpdate DescriptionChanged holds the new description`() { + val update = MetadataUpdate.DescriptionChanged("New description") + assertEquals("New description", update.newDescription) + } + @Test fun `MetadataUpdate PictureChanged holds the new picture`() { val picture = MediaItem(renditions = emptyList()) @@ -182,5 +188,16 @@ class DomainModelsTest { val parameters = EditChatParameters() assertNull(parameters.title) assertNull(parameters.picture) + assertNull(parameters.description) + } + + @Test + fun `EditChatParameters tells clearing a description apart from leaving it`() { + assertNull(EditChatParameters().description) + assertEquals(DescriptionEdit.Clear, EditChatParameters(description = DescriptionEdit.Clear).description) + assertEquals( + DescriptionEdit.Set("About"), + EditChatParameters(description = DescriptionEdit.Set("About")).description, + ) } }