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 8cd27dbf6..f0bfd59ca 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 8b512c3b7..467f01a73 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 283789557..4792e6c82 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 3cbc3cfd9..5a6ed8063 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 000000000..69e054825 --- /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 e7fd3dcf3..bff0da7a7 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 ce6242461..0b383376e 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 1781c99b4..5726c30d0 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 c10c80d97..628a752b7 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 e8d9f3b01..fa1ca43f4 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 d426513c7..8596eba6e 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7451f27c7..4b5916bc7 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, 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 2664b18f8..f2750b9d0 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 a99a30cdd..47076b2cf 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 5df4d9c48..1dbae2617 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 0945add42..a230f6041 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 8cfa6f93c..77b514b9e 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 81c19b591..4084edaec 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 17838ba99..f9687dbc3 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 c408a5fca..7b884d360 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 dbb4f24cb..530990999 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 f6591f971..94e8d710c 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 bb39857e6..b73abf7eb 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 000000000..c6537c03a --- /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 4e83eee82..23cb14055 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 cfa104914..7adaaa9b8 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 b468f6e2f..d4c2fa2f5 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 c823b6adc..18e97141f 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 636b2d2e6..d50324c7f 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()