From cf631a289998779f3de44534bfe97cc558abcc24 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:56:18 -0400 Subject: [PATCH 1/2] feat(chat): adopt flipcash2 cover picture, featured groups and chatter sampling contract Rename the picture fields to profile_picture and carry cover_picture into ChatMetadata, the metadata entity (Room 43 -> 44) and the DAO, where a feed upsert without a cover keeps the cached one. Map the renumbered EditChat result, the new StartChat cover case, CoverPictureChanged and RosterUpdate.MembershipChanged (applied by version without touching the member list). Add service, repository and controller wrappers with domain and error types for SampleChatters, SetFeaturedGroups and GetFeaturedGroups. --- .../shared/chat/internal/RosterStateHolder.kt | 5 + .../internal/delegates/EventStreamDelegate.kt | 4 + .../internal/delegates/GroupFeedDelegate.kt | 4 + .../shared/chat/RosterStateHolderTest.kt | 39 + .../44.json | 1048 +++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../app/persistence/dao/ChatMetadataDao.kt | 7 + .../entities/ChatMetadataEntity.kt | 4 + .../persistence/dao/ChatMetadataDaoTest.kt | 33 + .../sources/ChatMetadataDataSource.kt | 5 + .../sources/mapper/chat/ChatEntityMapper.kt | 2 + .../services/controllers/ChatController.kt | 24 + .../internal/domain/ChatMetadataMapper.kt | 3 +- .../services/internal/network/api/ChatApi.kt | 87 +- .../network/extensions/ProtobufToLocal.kt | 13 +- .../internal/network/services/ChatService.kt | 74 +- .../repositories/InternalChatRepository.kt | 37 + .../com/flipcash/services/models/Errors.kt | 35 + .../services/models/chat/ChatMetadata.kt | 3 + .../models/chat/EditChatParameters.kt | 3 + .../services/models/chat/MetadataUpdate.kt | 3 + .../services/models/chat/RosterChange.kt | 9 + .../services/models/chat/SampledChatter.kt | 26 + .../models/chat/StartChatParameters.kt | 4 + .../services/repository/ChatRepository.kt | 29 + .../controllers/ChatControllerTest.kt | 16 + .../extensions/ChatMetadataExtensionTest.kt | 64 + .../extensions/RosterUpdateExtensionTest.kt | 13 + 28 files changed, 1586 insertions(+), 11 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/44.json create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/SampledChatter.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RosterStateHolder.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RosterStateHolder.kt index 8cd27dbf6e..f0bfd59caf 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RosterStateHolder.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RosterStateHolder.kt @@ -49,6 +49,9 @@ class RosterStateHolder @Inject constructor( when { incoming <= stored -> continue + // The summary is all a membership change carries and all it needs: it edits no + // member, so a skipped version here is not a hole in the cached list to repair. + change is RosterChange.MembershipChanged -> applyChange(chatId, change) incoming > stored + 1 -> refetch(chatId, stored, incoming) else -> applyChange(chatId, change) } @@ -59,6 +62,8 @@ class RosterStateHolder @Inject constructor( when (change) { is RosterChange.MemberJoined -> memberDataSource.upsert(chatId, listOf(change.member)) is RosterChange.MemberLeft -> memberDataSource.deleteMember(chatId, change.userId) + // Summary only; the cached member list stays as it is. + is RosterChange.MembershipChanged -> Unit } metadataDataSource.updateRoster( chatId = chatId, 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 8b512c3b7d..467f01a732 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 @@ -433,6 +433,10 @@ class EventStreamDelegate @Inject constructor( // Same best-effort contract as TitleChanged above. metadataDataSource.updatePicture(chatId, metaUpdate.newPicture) } + is MetadataUpdate.CoverPictureChanged -> { + // Same best-effort contract as TitleChanged above. Replaces the cached cover. + metadataDataSource.updateCoverPicture(chatId, metaUpdate.newCoverPicture) + } } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index 283789557b..4792e6c82b 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -235,6 +235,10 @@ class GroupFeedDelegate @Inject constructor( // of the chat, whether you left from this device or another one. metadataDataSource.clearViewerState(chatId) } + + // No list-level meaning: it edits neither your membership nor the member list. + // The summary is applied by RosterStateHolder below. + is RosterChange.MembershipChanged -> Unit } } rosterStateHolder.apply(chatId, changes) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RosterStateHolderTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RosterStateHolderTest.kt index 3cbc3cfd9c..5a6ed80634 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RosterStateHolderTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RosterStateHolderTest.kt @@ -60,6 +60,45 @@ class RosterStateHolderTest { coEvery { metadataDataSource.getRosterVersion(chatId) } returns version } + @Test + fun `a membership change applies the summary and leaves the member list alone`() = runTest { + storedVersion(4) + + subject.apply( + chatId, + listOf(RosterChange.MembershipChanged(RosterSummary(memberCount = 12, version = 5))), + ) + + coVerify { metadataDataSource.updateRoster(chatId, memberCount = 12, rosterVersion = 5) } + coVerify(exactly = 0) { memberDataSource.upsert(any(), any()) } + coVerify(exactly = 0) { memberDataSource.deleteMember(any(), any()) } + } + + @Test + fun `a membership change that skips versions is applied without a refetch`() = runTest { + storedVersion(4) + + subject.apply( + chatId, + listOf(RosterChange.MembershipChanged(RosterSummary(memberCount = 12, version = 9))), + ) + + coVerify(exactly = 0) { controller.getChat(any(), any()) } + coVerify { metadataDataSource.updateRoster(chatId, memberCount = 12, rosterVersion = 9) } + } + + @Test + fun `a membership change at or below the stored version is dropped`() = runTest { + storedVersion(5) + + subject.apply( + chatId, + listOf(RosterChange.MembershipChanged(RosterSummary(memberCount = 12, version = 5))), + ) + + coVerify(exactly = 0) { metadataDataSource.updateRoster(any(), any(), any()) } + } + @Test fun `a join one version ahead is applied`() = runTest { storedVersion(4) diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/44.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/44.json new file mode 100644 index 0000000000..69e0548252 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/44.json @@ -0,0 +1,1048 @@ +{ + "formatVersion": 1, + "database": { + "version": 44, + "identityHash": "cd0c4e2a41dde80c81a922deaad364c7", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `description` TEXT, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd0c4e2a41dde80c81a922deaad364c7')" + ] + } +} \ 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 e7fd3dcf3c..bff0da7a7c 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 @@ -124,8 +124,9 @@ import com.getcode.utils.subByteArray // The pending_media table, an AutoMigration for the same reason as chat_draft: an entry // dropped by the destructive fallback is a photo the viewer queued and never sent. AutoMigration(from = 42, to = 43), // pending_media table + AutoMigration(from = 43, to = 44), // chat_metadata.cover_picture_json (nullable) ], - version = 43, + version = 44, ) @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 ce6242461e..0b383376ed 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 @@ -91,6 +91,7 @@ interface ChatMetadataDao { "title = :title, " + "description = :description, " + "picture_json = :pictureJson, " + + "cover_picture_json = COALESCE(:coverPictureJson, cover_picture_json), " + "rules_json = :rulesJson, " + "is_member = :isMember, " + "use_e2ee = :useE2ee " + @@ -105,6 +106,7 @@ interface ChatMetadataDao { title: String?, description: String?, pictureJson: MediaItem?, + coverPictureJson: MediaItem?, rulesJson: ChatRulesSerialized?, isMember: Boolean, useE2ee: Boolean, @@ -206,6 +208,7 @@ interface ChatMetadataDao { title = entity.title, description = entity.description, pictureJson = entity.pictureJson, + coverPictureJson = entity.coverPictureJson, rulesJson = entity.rulesJson, isMember = entity.isMember, useE2ee = entity.useE2ee, @@ -325,6 +328,10 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET picture_json = :pictureJson WHERE chat_id_hex = :chatIdHex") suspend fun updatePicture(chatIdHex: String, pictureJson: MediaItem) + /** Unconditional, like [updatePicture]: `MetadataUpdate.CoverPictureChanged` carries no version. */ + @Query("UPDATE chat_metadata SET cover_picture_json = :coverPictureJson WHERE chat_id_hex = :chatIdHex") + suspend fun updateCoverPicture(chatIdHex: String, coverPictureJson: MediaItem) + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } 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 1781c99b44..5726c30d09 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 @@ -30,6 +30,10 @@ data class ChatMetadataEntity( val title: String? = null, @ColumnInfo(name = "picture_json") val pictureJson: MediaItem? = null, + // Group cover picture. Feed RPCs may omit it even when one is set, so only an explicit value + // (GetChat, a CoverPictureChanged update) writes it; an absent one never clears it. + @ColumnInfo(name = "cover_picture_json") + val coverPictureJson: MediaItem? = null, // The roster's true size and the version that decides whether a roster write applies. // Zero on a DM, and on any group whose metadata was rebuilt without a server round trip. @ColumnInfo(name = "member_count", defaultValue = "0") diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index c10c80d97e..628a752b70 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 @@ -55,6 +55,7 @@ class ChatMetadataDaoTest { title: String? = null, description: String? = null, pictureJson: MediaItem? = null, + coverPictureJson: MediaItem? = null, memberCount: Long = 0, rosterVersion: Long = 0, rulesJson: ChatRulesSerialized? = null, @@ -75,6 +76,7 @@ class ChatMetadataDaoTest { title = title, description = description, pictureJson = pictureJson, + coverPictureJson = coverPictureJson, memberCount = memberCount, rosterVersion = rosterVersion, rulesJson = rulesJson, @@ -246,6 +248,37 @@ class ChatMetadataDaoTest { assertEquals(picture, dao.getById(CHAT_HEX)?.pictureJson) } + @Test + fun `updateCoverPicture overwrites the cover unconditionally`() = runTest { + dao.upsert(entity(chatType = "GROUP")) + val cover = MediaItem(renditions = emptyList()) + + dao.updateCoverPicture(CHAT_HEX, cover) + + assertEquals(cover, dao.getById(CHAT_HEX)?.coverPictureJson) + } + + /** Feed RPCs may omit the cover even when one is set, so an absent one must not clear it. */ + @Test + fun `upsert without a cover keeps the cached cover`() = runTest { + val cover = MediaItem(renditions = emptyList()) + dao.upsert(entity(chatType = "GROUP", coverPictureJson = cover)) + + dao.upsert(entity(chatType = "GROUP", coverPictureJson = null)) + + assertEquals(cover, dao.getById(CHAT_HEX)?.coverPictureJson) + } + + @Test + fun `upsert with a cover replaces the cached cover`() = runTest { + val cover = MediaItem(renditions = emptyList()) + dao.upsert(entity(chatType = "GROUP")) + + dao.upsert(entity(chatType = "GROUP", coverPictureJson = cover)) + + assertEquals(cover, dao.getById(CHAT_HEX)?.coverPictureJson) + } + @Test fun `a newer roster version replaces the roster columns`() = runTest { dao.upsert(entity(chatType = "GROUP", memberCount = 12, rosterVersion = 4)) 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 e8d9f3b01c..fa1ca43f45 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 @@ -165,6 +165,11 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.updatePicture(mapper.chatIdHex(chatId), picture) } + /** Applies a `MetadataUpdate.CoverPictureChanged` for [chatId]. See ChatMetadataDao.updateCoverPicture. */ + suspend fun updateCoverPicture(chatId: ChatId, coverPicture: MediaItem) { + db?.chatMetadataDao()?.updateCoverPicture(mapper.chatIdHex(chatId), coverPicture) + } + suspend fun getAnalyticsCountedThrough(chatId: ChatId): Long = db?.chatMetadataDao()?.getAnalyticsCountedThrough(mapper.chatIdHex(chatId)) ?: 0L 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 d426513c74..8596eba6e2 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 @@ -89,6 +89,7 @@ class ChatEntityMapper @Inject constructor() { title = metadata.title, description = metadata.description, pictureJson = metadata.picture, + coverPictureJson = metadata.coverPicture, memberCount = metadata.rosterSummary.memberCount, rosterVersion = metadata.rosterSummary.version, rulesJson = metadata.rules?.toSerialized(), @@ -120,6 +121,7 @@ class ChatEntityMapper @Inject constructor() { title = entity.title, description = entity.description, picture = entity.pictureJson, + coverPicture = entity.coverPictureJson, rosterSummary = RosterSummary( memberCount = entity.memberCount, version = entity.rosterVersion, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index 2664b18f83..f2750b9d01 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -5,6 +5,7 @@ import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.KeyEnvelope @@ -90,6 +91,29 @@ class ChatController @Inject constructor( return repository.getRoster(owner, chatId, queryOptions) } + /** + * A sample of [chatId]'s chatters; public groups only. Works without an account cluster, so a + * preview viewer can call it -- the sample is the same either way. + */ + suspend fun sampleChatters(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + return repository.sampleChatters(owner, chatId) + } + + /** Replaces the caller's featured groups. See [ChatRepository.setFeaturedGroups]. */ + suspend fun setFeaturedGroups(chatIds: List): Result> { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setFeaturedGroups(owner, chatIds) + } + + /** [username]'s featured groups. See [ChatRepository.getFeaturedGroups]. */ + suspend fun getFeaturedGroups(username: String): Result> { + val owner = userManager.accountCluster?.authority?.keyPair + return repository.getFeaturedGroups(owner, username) + } + /** The pool [chatId] offers for `@` mentions. See [ChatRepository.getMentionSuggestions]. */ suspend fun getMentionSuggestions(chatId: ChatId): Result> { val owner = userManager.accountCluster?.authority?.keyPair 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 a99a30cdde..47076b2cfe 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 @@ -47,7 +47,8 @@ class ChatMetadataMapper @Inject constructor( isHidden = from.isHidden, title = from.title.takeIf { it.isNotEmpty() }, description = from.description.takeIf { it.isNotEmpty() }, - picture = if (from.hasPicture()) from.picture.toMediaItem() else null, + picture = if (from.hasProfilePicture()) from.profilePicture.toMediaItem() else null, + coverPicture = if (from.hasCoverPicture()) from.coverPicture.toMediaItem() else null, rosterSummary = from.rosterSummary.toRosterSummary(), rules = if (from.hasRules()) from.rules.toChatRules() else null, viewerState = if (from.hasViewerState()) from.viewerState.toViewerState() 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 5df4d9c48b..1dbae26170 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 @@ -12,6 +12,7 @@ import com.flipcash.services.internal.network.extensions.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asProtoKeyEnvelope import com.flipcash.services.internal.network.extensions.asUserId +import com.flipcash.services.internal.network.extensions.asUsername import com.flipcash.services.internal.network.extensions.asViewMode import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions @@ -114,7 +115,15 @@ internal class ChatApi @Inject constructor( .apply { parameters.description?.let { setDescription(it) } } .apply { parameters.picture?.let { - setPicture( + setProfilePicture( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(it.bytes.toByteString()) + ) + } + } + .apply { + parameters.coverPicture?.let { + setCoverPicture( com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() .setValue(it.bytes.toByteString()) ) @@ -128,7 +137,15 @@ internal class ChatApi @Inject constructor( .apply { parameters.description?.let { setDescription(it) } } .apply { parameters.picture?.let { - setPicture( + setProfilePicture( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(it.bytes.toByteString()) + ) + } + } + .apply { + parameters.coverPicture?.let { + setCoverPicture( com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() .setValue(it.bytes.toByteString()) ) @@ -167,6 +184,59 @@ internal class ChatApi @Inject constructor( } } + /** + * Public groups only. [owner] is optional: the sample is the same with or without auth, and + * a viewer who is not in the chat (preview) can call it unauthenticated. + */ + suspend fun sampleChatters( + owner: KeyPair?, + chatId: ChatId, + ): RpcChatService.SampleChattersResponse { + val request = RpcChatService.SampleChattersRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { owner?.let { setAuth(authenticate(it)) } } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.sampleChatters(request) + } + } + + suspend fun setFeaturedGroups( + owner: KeyPair, + chatIds: List, + ): RpcChatService.SetFeaturedGroupsResponse { + val request = RpcChatService.SetFeaturedGroupsRequest.newBuilder() + .addAllChatIds(chatIds.map { it.asChatId() }) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setFeaturedGroups(request) + } + } + + /** [owner] is optional: the list is the same with or without auth. */ + suspend fun getFeaturedGroups( + owner: KeyPair?, + username: String, + ): RpcChatService.GetFeaturedGroupsResponse { + val request = RpcChatService.GetFeaturedGroupsRequest.newBuilder() + .setUsername(username.asUsername()) + .apply { owner?.let { setAuth(authenticate(it)) } } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getFeaturedGroups(request) + } + } + suspend fun getMentionSuggestions( owner: KeyPair, chatId: ChatId, @@ -209,8 +279,17 @@ internal class ChatApi @Inject constructor( ) } parameters.picture?.let { - setPicture( - RpcChatService.EditChatRequest.Picture.newBuilder() + setProfilePicture( + RpcChatService.EditChatRequest.ProfilePicture.newBuilder() + .setBlobId( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(it.bytes.toByteString()) + ) + ) + } + parameters.coverPicture?.let { + setCoverPicture( + RpcChatService.EditChatRequest.CoverPicture.newBuilder() .setBlobId( com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() .setValue(it.bytes.toByteString()) 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 0945add42b..a230f60410 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 @@ -392,8 +392,10 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate( MetadataUpdate.TitleChanged(titleChanged.newTitle) ChatModel.MetadataUpdate.KindCase.DESCRIPTION_CHANGED -> MetadataUpdate.DescriptionChanged(descriptionChanged.newDescription) - ChatModel.MetadataUpdate.KindCase.PICTURE_CHANGED -> - MetadataUpdate.PictureChanged(pictureChanged.newPicture.toMediaItem()) + ChatModel.MetadataUpdate.KindCase.PROFILE_PICTURE_CHANGED -> + MetadataUpdate.PictureChanged(profilePictureChanged.newProfilePicture.toMediaItem()) + ChatModel.MetadataUpdate.KindCase.COVER_PICTURE_CHANGED -> + MetadataUpdate.CoverPictureChanged(coverPictureChanged.newCoverPicture.toMediaItem()) else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) } } @@ -422,7 +424,8 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { isHidden = isHidden, title = title.takeIf { it.isNotEmpty() }, description = description.takeIf { it.isNotEmpty() }, - picture = if (hasPicture()) picture.toMediaItem() else null, + picture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, + coverPicture = if (hasCoverPicture()) coverPicture.toMediaItem() else null, rosterSummary = rosterSummary.toRosterSummary(), rules = if (hasRules()) rules.toChatRules() else null, viewerState = if (hasViewerState()) viewerState.toViewerState() else null, @@ -521,6 +524,10 @@ internal fun ChatModel.RosterUpdate.toRosterChangeOrNull( rosterSummary = rosterSummary.toRosterSummary(), ) + ChatModel.RosterUpdate.KindCase.MEMBERSHIP_CHANGED -> RosterChange.MembershipChanged( + rosterSummary = rosterSummary.toRosterSummary(), + ) + 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 8cfa6f93ca..77b514b9e1 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 @@ -20,7 +20,10 @@ import com.flipcash.services.models.GetRosterError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.MuteChatError +import com.flipcash.services.models.GetFeaturedGroupsError import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.SampleChattersError +import com.flipcash.services.models.SetFeaturedGroupsError import com.flipcash.services.models.StartChatError import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatId @@ -131,6 +134,71 @@ internal class ChatService @Inject constructor( ) } + suspend fun sampleChatters( + owner: KeyPair?, + chatId: ChatId, + ): Result { + return runCatching { + api.sampleChatters(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.SampleChattersResponse.Result.OK -> Result.success(response) + RpcChatService.SampleChattersResponse.Result.DENIED -> Result.failure(SampleChattersError.Denied()) + RpcChatService.SampleChattersResponse.Result.NOT_FOUND -> Result.failure(SampleChattersError.NotFound()) + RpcChatService.SampleChattersResponse.Result.UNRECOGNIZED -> Result.failure(SampleChattersError.Unrecognized()) + else -> Result.failure(SampleChattersError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { SampleChattersError.Other(cause = it) }) + } + ) + } + + suspend fun setFeaturedGroups( + owner: KeyPair, + chatIds: List, + ): Result { + return runCatching { + api.setFeaturedGroups(owner, chatIds) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.SetFeaturedGroupsResponse.Result.OK -> Result.success(response) + RpcChatService.SetFeaturedGroupsResponse.Result.DENIED -> Result.failure(SetFeaturedGroupsError.Denied()) + RpcChatService.SetFeaturedGroupsResponse.Result.NOT_FOUND -> Result.failure(SetFeaturedGroupsError.NotFound()) + RpcChatService.SetFeaturedGroupsResponse.Result.UNRECOGNIZED -> Result.failure(SetFeaturedGroupsError.Unrecognized()) + else -> Result.failure(SetFeaturedGroupsError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { SetFeaturedGroupsError.Other(cause = it) }) + } + ) + } + + suspend fun getFeaturedGroups( + owner: KeyPair?, + username: String, + ): Result { + return runCatching { + api.getFeaturedGroups(owner, username) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetFeaturedGroupsResponse.Result.OK -> Result.success(response) + RpcChatService.GetFeaturedGroupsResponse.Result.NOT_FOUND -> Result.failure(GetFeaturedGroupsError.NotFound()) + RpcChatService.GetFeaturedGroupsResponse.Result.UNRECOGNIZED -> Result.failure(GetFeaturedGroupsError.Unrecognized()) + else -> Result.failure(GetFeaturedGroupsError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetFeaturedGroupsError.Other(cause = it) }) + } + ) + } + suspend fun getMentionSuggestions( owner: KeyPair, chatId: ChatId, @@ -192,7 +260,8 @@ internal class ChatService @Inject constructor( 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.PROFILE_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(StartChatError.PictureBlobNotAccepted()) + RpcChatService.StartChatResponse.Result.COVER_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(StartChatError.CoverPictureBlobNotAccepted()) RpcChatService.StartChatResponse.Result.INVALID_RULES -> Result.failure(StartChatError.InvalidRules()) RpcChatService.StartChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(StartChatError.RulesNotSatisfied()) RpcChatService.StartChatResponse.Result.UNRECOGNIZED -> Result.failure(StartChatError.Unrecognized()) @@ -222,7 +291,8 @@ internal class ChatService @Inject constructor( 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.PROFILE_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.PictureBlobNotAccepted()) + RpcChatService.EditChatResponse.Result.COVER_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.CoverPictureBlobNotAccepted()) RpcChatService.EditChatResponse.Result.UNRECOGNIZED -> Result.failure(EditChatError.Unrecognized()) else -> Result.failure(EditChatError.Other()) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index 81c19b5916..4084edaec9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -11,10 +11,13 @@ import com.flipcash.services.internal.network.extensions.toRosterSummary import com.flipcash.services.internal.network.extensions.toViewerState import com.flipcash.services.internal.network.services.ChatService import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.SampleChattersError import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatterSample +import com.flipcash.services.models.chat.SampledChatter import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey @@ -39,6 +42,40 @@ internal class InternalChatRepository( private val mapper: ChatMetadataMapper, private val userProfileMapper: UserProfileMapper, ) : ChatRepository { + override suspend fun sampleChatters( + owner: KeyPair?, + chatId: ChatId, + ): Result = service.sampleChatters(owner, chatId) + .onFailure { if (it !is SampleChattersError.Denied) ErrorUtils.handleError(it) } + .map { response -> + ChatterSample( + chatters = response.chattersList.map { chatter -> + SampledChatter( + userProfile = userProfileMapper.map(chatter.userProfile), + lastSentAt = if (chatter.hasLastSentAt()) { + Instant.fromEpochSeconds(chatter.lastSentAt.seconds, chatter.lastSentAt.nanos) + } else null, + isCreator = chatter.isCreator, + ) + }, + hasMore = response.hasMore, + ) + } + + override suspend fun setFeaturedGroups( + owner: KeyPair, + chatIds: List, + ): Result> = service.setFeaturedGroups(owner, chatIds) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> response.featuredGroupsList.map { mapper.map(it) } } + + override suspend fun getFeaturedGroups( + owner: KeyPair?, + username: String, + ): Result> = service.getFeaturedGroups(owner, username) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> response.featuredGroupsList.map { mapper.map(it) } } + override suspend fun getChat( owner: KeyPair, chatId: ChatId, 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 17838ba99c..f9687dbc31 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 @@ -413,6 +413,39 @@ sealed class GetRosterError( data class Other(override val cause: Throwable? = null) : GetRosterError(message = cause?.message, cause = cause), NotifiableError } +sealed class SampleChattersError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + // Not a public group (private group or DM). + class Denied : SampleChattersError("Denied") + class NotFound : SampleChattersError("Not found") + class Unrecognized : SampleChattersError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SampleChattersError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class SetFeaturedGroupsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + // A group in the request is private; nothing was written. + class Denied : SetFeaturedGroupsError("Denied") + // A group in the request does not exist; nothing was written. + class NotFound : SetFeaturedGroupsError("Not found") + class Unrecognized : SetFeaturedGroupsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetFeaturedGroupsError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetFeaturedGroupsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + // No user holds the username. + class NotFound : GetFeaturedGroupsError("Not found") + class Unrecognized : GetFeaturedGroupsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetFeaturedGroupsError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetMentionSuggestionsError( override val message: String? = null, override val cause: Throwable? = null @@ -457,6 +490,7 @@ sealed class StartChatError( 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 CoverPictureBlobNotAccepted : StartChatError("Cover 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 // minimum balance requirement). Recoverable by the user, so deliberately not a @@ -475,6 +509,7 @@ sealed class EditChatError( 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 CoverPictureBlobNotAccepted : EditChatError("Cover 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 c408a5fca6..7b884d3600 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 @@ -17,6 +17,9 @@ data class ChatMetadata( val description: String? = null, // Picture for this chat. Only set for group chats. val picture: MediaItem? = null, + // Cover picture for this chat. Only set for group chats. Feed RPCs may leave it unset even + // when one exists, so a feed result must never clear a cached cover -- fetch with GetChat. + val coverPicture: MediaItem? = null, // True roster size and staleness version. Server-authoritative; defaults to zero for // metadata reconstructed without a server round trip. val rosterSummary: RosterSummary = RosterSummary(memberCount = 0, version = 0), 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 dbb4f24cbf..530990999d 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,6 +11,9 @@ 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, + // The already-uploaded-and-READY blob to use as the new cover picture. Null leaves the cover + // unchanged. + val coverPicture: BlobId? = null, // Null leaves the description unchanged; see [DescriptionEdit] for set vs clear. val description: DescriptionEdit? = null, ) 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 f6591f9712..94e8d710ce 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 @@ -15,4 +15,7 @@ sealed interface MetadataUpdate { 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 + // A group chat's cover picture was edited; replaces the cached cover. Best-effort delivery, + // same caveat as TitleChanged. + data class CoverPictureChanged(val newCoverPicture: MediaItem) : MetadataUpdate } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterChange.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterChange.kt index bb39857e6b..b73abf7ebd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterChange.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterChange.kt @@ -38,4 +38,13 @@ sealed interface RosterChange { val userId: ID, override val rosterSummary: RosterSummary, ) : RosterChange + + /** + * Membership changed in a way that needs no member list edit. Carries only the roster's + * summary after the change: apply its count and version, and leave the cached member list + * alone. Unlike a join or leave, it is never a version gap to repair with a refetch. + */ + data class MembershipChanged( + override val rosterSummary: RosterSummary, + ) : RosterChange } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/SampledChatter.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/SampledChatter.kt new file mode 100644 index 0000000000..c6537c03a6 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/SampledChatter.kt @@ -0,0 +1,26 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.UserProfile +import kotlin.time.Instant + +/** + * One member in a sample of a public group's chatters, from `Chat.SampleChatters`. + */ +data class SampledChatter( + val userProfile: UserProfile, + // When the member last sent a message, as the server records it (it may trail their latest + // message by up to a minute). Null for a creator who has not sent recently. + val lastSentAt: Instant?, + val isCreator: Boolean, +) + +/** + * A sample of a public group's chatters: the creator first (when a member), then the most recent + * senders, most recent first. At most 100 entries. + */ +data class ChatterSample( + val chatters: List, + // True when the server knows of more recent senders than the sample holds, and also when it + // stopped looking before it could tell. + val hasMore: Boolean, +) 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 4e83eee82c..23cb140554 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 @@ -20,6 +20,8 @@ sealed interface StartChatParameters { * participation requirements. The caller must satisfy the rules for the chat to start. */ val rules: ChatRules? = null, + /** The blob holding the ORIGINAL cover picture. Optional; must be owned by the caller and READY. */ + val coverPicture: BlobId? = null, ) : StartChatParameters /** @@ -36,5 +38,7 @@ sealed interface StartChatParameters { val description: String? = null, /** The blob holding the ORIGINAL picture the caller uploaded. Optional. */ val picture: BlobId? = null, + /** The blob holding the ORIGINAL cover picture. Optional; must be owned by the caller and READY. */ + val coverPicture: BlobId? = null, ) : StartChatParameters } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index cfa104914c..7adaaa9b88 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -6,6 +6,7 @@ import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey @@ -23,6 +24,34 @@ import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID interface ChatRepository { + /** + * A sample of a public group's chatters. [owner] is optional: the sample is the same with or + * without it, so a viewer who is not a member (preview) can call it. Fails with + * `SampleChattersError.Denied` for a private group or DM. + */ + suspend fun sampleChatters( + owner: KeyPair?, + chatId: ChatId, + ): Result + + /** + * Replaces the caller's featured groups with [chatIds], in the order to show them (at most + * 10, all public groups, no repeats; empty clears). Returns the list as stored. + */ + suspend fun setFeaturedGroups( + owner: KeyPair, + chatIds: List, + ): Result> + + /** + * [username]'s featured groups, in their order. [owner] is optional. The returned metadata is + * list-view shaped: no members, viewer state, last message or cover picture. + */ + suspend fun getFeaturedGroups( + owner: KeyPair?, + username: String, + ): Result> + suspend fun getChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index b468f6e2f9..d4c2fa2f53 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -6,6 +6,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.KeyEnvelope @@ -548,6 +549,21 @@ private class FakeChatRepository : ChatRepository { chatId: ChatId, ): Result> = Result.success(emptyList()) + override suspend fun sampleChatters( + owner: Ed25519.KeyPair?, + chatId: ChatId, + ): Result = Result.success(ChatterSample(emptyList(), hasMore = false)) + + override suspend fun setFeaturedGroups( + owner: Ed25519.KeyPair, + chatIds: List, + ): Result> = Result.success(emptyList()) + + override suspend fun getFeaturedGroups( + owner: Ed25519.KeyPair?, + username: String, + ): Result> = Result.success(emptyList()) + override suspend fun getRoster( owner: Ed25519.KeyPair, chatId: ChatId, 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 c823b6adc2..18e97141fb 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 @@ -8,6 +8,7 @@ import com.google.protobuf.Timestamp import com.flipcash.services.models.chat.MetadataUpdate import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull /** @@ -104,4 +105,67 @@ class ChatMetadataExtensionTest { assertEquals(MetadataUpdate.DescriptionChanged(""), result) } + + private fun media(byte: Byte): com.codeinc.flipcash.gen.blob.v1.Model.Media = + com.codeinc.flipcash.gen.blob.v1.Model.Media.newBuilder() + .addRenditions( + com.codeinc.flipcash.gen.blob.v1.Model.Rendition.newBuilder() + .setRole(com.codeinc.flipcash.gen.blob.v1.Model.Rendition.Role.ORIGINAL) + .setBlobId( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(16) { byte })) + ) + ) + .build() + + @Test + fun `metadata maps profile and cover pictures independently`() { + val proto = metadata( + ChatModel.Member.newBuilder().setUserId(userId(9)).build() + ).toBuilder() + .setProfilePicture(media(1)) + .setCoverPicture(media(2)) + .build() + + val result = proto.toChatMetadata() + + assertEquals(ByteArray(16) { 1 }.toList(), result.picture?.renditions?.single()?.blobId?.bytes?.toList()) + assertEquals(ByteArray(16) { 2 }.toList(), result.coverPicture?.renditions?.single()?.blobId?.bytes?.toList()) + } + + @Test + fun `metadata without a cover maps to a null cover`() { + val result = metadata( + ChatModel.Member.newBuilder().setUserId(userId(9)).build() + ).toChatMetadata() + + assertNull(result.coverPicture) + } + + @Test + fun `profile picture changed maps to PictureChanged`() { + val update = ChatModel.MetadataUpdate.newBuilder() + .setProfilePictureChanged( + ChatModel.MetadataUpdate.ProfilePictureChanged.newBuilder().setNewProfilePicture(media(1)) + ) + .build() + + val result = update.toMetadataUpdate { it.toChatMetadata() } + + assertIs(result) + } + + @Test + fun `cover picture changed maps to CoverPictureChanged`() { + val update = ChatModel.MetadataUpdate.newBuilder() + .setCoverPictureChanged( + ChatModel.MetadataUpdate.CoverPictureChanged.newBuilder().setNewCoverPicture(media(2)) + ) + .build() + + val result = update.toMetadataUpdate { it.toChatMetadata() } + + val changed = assertIs(result) + assertEquals(ByteArray(16) { 2 }.toList(), changed.newCoverPicture.renditions.single().blobId.bytes.toList()) + } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt index 636b2d2e6a..d50324c7f8 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/RosterUpdateExtensionTest.kt @@ -123,6 +123,19 @@ class RosterUpdateExtensionTest { assertNull(update.toRosterChangeOrNull()) } + @Test + fun `membership changed carries only the post-change summary`() { + val update = ChatModel.RosterUpdate.newBuilder() + .setMembershipChanged(ChatModel.RosterUpdate.MembershipChanged.newBuilder()) + .setRosterSummary(rosterSummary(memberCount = 5, version = 11)) + .build() + + val change = assertIs(update.toRosterChangeOrNull()) + + assertEquals(5L, change.rosterSummary.memberCount) + assertEquals(11L, change.rosterSummary.version) + } + @Test fun `chat update carries the roster batch`() { val update = EventModel.ChatUpdate.newBuilder() From 2c63487a2483709330b0b45089ab838ad3e8a4ab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 13:19:50 -0400 Subject: [PATCH 2/2] chore(deps): bump flipcash2-client-protocol to 0.18.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7451f27c76..4b5916bc70 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.17.0" +flipcash2-client-protocol = "0.18.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,