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 93da1bfc5b..5f155d4d09 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 @@ -458,6 +458,11 @@ class EventStreamDelegate @Inject constructor( // replacement rules, so they overwrite the cached ones. metadataDataSource.updateRules(chatId, metaUpdate.newRules) } + is MetadataUpdate.PinsChanged -> { + // Convergent like the roster: applied only when strictly newer than the held + // summary, so a replayed or reordered event cannot roll the pins back. + metadataDataSource.updatePinSummary(chatId, metaUpdate.summary) + } } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PinsChangedStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PinsChangedStreamTest.kt new file mode 100644 index 0000000000..50612cc1a0 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PinsChangedStreamTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.models.chat.PinChange +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import org.junit.Test + +/** + * A `PinsChanged` event hands its summary to the data source, which applies it only when strictly + * newer than the held one (see `ChatMetadataDaoTest` for that rule). + */ +class PinsChangedStreamTest { + + @Test + fun `a pins changed event offers its summary to the held pins`() { + val chatId = ChatId("aabbccdd") + val summary = PinSummary(count = 1, version = 7) + val update = ChatUpdate( + chatId = chatId, + metadataUpdates = listOf( + MetadataUpdate.PinsChanged(change = PinChange.Unpinned(messageId = 9), summary = summary), + ), + ) + val streaming = mockk(relaxed = true) + every { streaming.isConnected } returns true + every { streaming.chatUpdates } returns flowOf(update) + val metadata = mockk(relaxed = true) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + try { + EventStreamDelegate( + eventStreamingController = streaming, + messagingController = mockk(relaxed = true), + metadataDataSource = metadata, + messageDataSource = mockk(relaxed = true), + memberDataSource = mockk(relaxed = true), + tokenCoordinator = mockk(relaxed = true), + userManager = mockk(relaxed = true), + stateHolder = ChatStateHolder(), + analytics = mockk(relaxed = true), + exchange = mockk(relaxed = true), + ).apply { + initialize(scope) + open() + } + + io.mockk.coVerify(timeout = 5_000) { metadata.updatePinSummary(chatId, summary) } + } finally { + scope.cancel() + } + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/48.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/48.json new file mode 100644 index 0000000000..b90d243e90 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/48.json @@ -0,0 +1,1107 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "ead69a1ed8e5dc0d82ada844cd1ba9fb", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `can_silence` INTEGER NOT NULL DEFAULT 0, `can_ban` INTEGER NOT NULL DEFAULT 0, `silence_until_epoch_ms` INTEGER, `silence_forever` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, `pin_count` INTEGER NOT NULL DEFAULT 0, `pin_version` INTEGER NOT NULL DEFAULT 0, `latest_pin` BLOB, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canSilence", + "columnName": "can_silence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canBan", + "columnName": "can_ban", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "silenceUntilEpochMs", + "columnName": "silence_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "silenceForever", + "columnName": "silence_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "pinCount", + "columnName": "pin_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pinVersion", + "columnName": "pin_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestPin", + "columnName": "latest_pin", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + } + ], + "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, 'ead69a1ed8e5dc0d82ada844cd1ba9fb')" + ] + } +} \ 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 8295270ba2..d9e1127e81 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 @@ -130,8 +130,10 @@ import com.getcode.utils.subByteArray // chat_metadata moderation: can_silence, can_ban (default 0), silence_until_epoch_ms // (nullable), silence_forever (default 0) AutoMigration(from = 46, to = 47), + // chat_metadata pin summary: pin_count, pin_version (default 0), latest_pin (nullable blob) + AutoMigration(from = 47, to = 48), ], - version = 47, + version = 48, ) @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 619b6cc59c..a3d86db4bc 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 @@ -211,6 +211,31 @@ interface ChatMetadataDao { ) suspend fun clearViewerState(chatIdHex: String) + /** + * Stores a pin summary read from the server unless the stored one is newer. Equal or higher + * [version] replaces; a lower one is dropped. A chat that has never had a pin reads as + * version 0, so any summary beats it, and a metadata carrying no summary (which maps to + * version 0, count 0, no latest) rewrites only that same empty state: a list RPC that omits + * the summary can never clear a held one. One statement, so the compare and the write are + * atomic. + */ + @Query( + "UPDATE chat_metadata SET pin_count = :count, pin_version = :version, latest_pin = :latestPin " + + "WHERE chat_id_hex = :chatIdHex AND :version >= pin_version" + ) + suspend fun mergePinSummary(chatIdHex: String, count: Int, version: Long, latestPin: ByteArray?) + + /** + * Applies a `PinsChanged` summary only when [version] is strictly newer than the stored one, + * returning whether a row changed. False also covers a chat with no row yet; its next feed or + * `GetChat` read carries the summary. + */ + @Query( + "UPDATE chat_metadata SET pin_count = :count, pin_version = :version, latest_pin = :latestPin " + + "WHERE chat_id_hex = :chatIdHex AND :version > pin_version" + ) + suspend fun applyPinsChanged(chatIdHex: String, count: Int, version: Long, latestPin: ByteArray?): Int + /** * Inserts a new chat, or refreshes an existing one's server-owned columns in place. * @@ -257,6 +282,12 @@ interface ChatMetadataDao { silenceUntilEpochMs = entity.silenceUntilEpochMs, silenceForever = entity.silenceForever, ) + mergePinSummary( + chatIdHex = entity.chatIdHex, + count = entity.pinCount, + version = entity.pinVersion, + latestPin = entity.latestPin, + ) } @Transaction 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 4af5c2d2eb..cd15f4d4a2 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 @@ -96,4 +96,15 @@ data class ChatMetadataEntity( // Group description, up to 160 characters; null when unset or on a DM. @ColumnInfo(name = "description") val description: String? = null, + // The chat's pin summary, three columns because the summary is compared and replaced as a + // unit. pin_version decides whether a write applies (see ChatMetadataDao.mergePinSummary); + // zero with no latest pin reads as "never had a pin". latest_pin is the serialized + // chat.v1.Pin, kept as the server sent it because the pinned message need not exist in the + // local messages table. Compared by identity in equals, like any array in a data class. + @ColumnInfo(name = "pin_count", defaultValue = "0") + val pinCount: Int = 0, + @ColumnInfo(name = "pin_version", defaultValue = "0") + val pinVersion: Long = 0, + @ColumnInfo(name = "latest_pin", typeAffinity = ColumnInfo.BLOB) + val latestPin: ByteArray? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index aff56727fc..dc300fb83f 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 @@ -16,6 +16,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNull @@ -69,6 +70,9 @@ class ChatMetadataDaoTest { canInvite: Boolean = false, useE2ee: Boolean = false, isPrivate: Boolean = false, + pinCount: Int = 0, + pinVersion: Long = 0, + latestPin: ByteArray? = null, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -93,6 +97,9 @@ class ChatMetadataDaoTest { canInvite = canInvite, useE2ee = useE2ee, isPrivate = isPrivate, + pinCount = pinCount, + pinVersion = pinVersion, + latestPin = latestPin, ) @Test @@ -744,6 +751,100 @@ class ChatMetadataDaoTest { } } + @Test + fun `a new row starts with no pins`() = runTest { + dao.upsert(entity()) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(0, stored.pinCount) + assertEquals(0L, stored.pinVersion) + assertNull(stored.latestPin) + } + + @Test + fun `the latest pin blob round-trips byte for byte`() = runTest { + val bytes = ByteArray(300) { (it * 7).toByte() } + dao.upsert(entity(pinCount = 2, pinVersion = 5, latestPin = bytes)) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(2, stored.pinCount) + assertEquals(5L, stored.pinVersion) + assertContentEquals(bytes, stored.latestPin) + } + + @Test + fun `an upsert without a pin summary does not clear the stored one`() = runTest { + dao.upsert(entity(pinCount = 2, pinVersion = 5, latestPin = byteArrayOf(1, 2))) + + // A list RPC omits the summary; the mapper writes the empty defaults. + dao.upsert(entity(lastActivityEpochMs = 2_000)) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(2, stored.pinCount) + assertEquals(5L, stored.pinVersion) + assertContentEquals(byteArrayOf(1, 2), stored.latestPin) + } + + @Test + fun `an upsert with an older pin summary keeps the stored one`() = runTest { + dao.upsert(entity(pinCount = 2, pinVersion = 5, latestPin = byteArrayOf(1))) + + dao.upsert(entity(pinCount = 1, pinVersion = 4, latestPin = byteArrayOf(9))) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(5L, stored.pinVersion) + assertContentEquals(byteArrayOf(1), stored.latestPin) + } + + @Test + fun `an upsert with an equal or newer pin summary replaces the stored one`() = runTest { + dao.upsert(entity(pinCount = 2, pinVersion = 5, latestPin = byteArrayOf(1))) + + dao.upsert(entity(pinCount = 3, pinVersion = 5, latestPin = byteArrayOf(2))) + assertEquals(3, requireNotNull(dao.getById(CHAT_HEX)).pinCount) + + dao.upsert(entity(pinCount = 0, pinVersion = 6, latestPin = null)) + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(0, stored.pinCount) + assertEquals(6L, stored.pinVersion) + assertNull(stored.latestPin) + } + + @Test + fun `a pins event applies only when strictly newer`() = runTest { + dao.upsert(entity(pinCount = 1, pinVersion = 5, latestPin = byteArrayOf(1))) + + assertEquals(0, dao.applyPinsChanged(CHAT_HEX, 0, 5, null)) + assertEquals(0, dao.applyPinsChanged(CHAT_HEX, 0, 4, null)) + assertEquals(1, requireNotNull(dao.getById(CHAT_HEX)).pinCount) + + assertEquals(1, dao.applyPinsChanged(CHAT_HEX, 4, 6, byteArrayOf(3))) + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(4, stored.pinCount) + assertEquals(6L, stored.pinVersion) + assertContentEquals(byteArrayOf(3), stored.latestPin) + } + + @Test + fun `a pins event on a chat that never had a pin applies, and on an unknown chat is dropped`() = runTest { + dao.upsert(entity()) + + assertEquals(1, dao.applyPinsChanged(CHAT_HEX, 1, 1, byteArrayOf(1))) + assertEquals(0, dao.applyPinsChanged(OTHER_HEX, 1, 1, byteArrayOf(1))) + } + + @Test + fun `observeById carries the stored pin summary`() = runTest { + dao.upsert(entity(pinCount = 1, pinVersion = 2, latestPin = byteArrayOf(4))) + + dao.observeById(CHAT_HEX).test { + val row = requireNotNull(awaitItem()) + assertEquals(2L, row.pinVersion) + assertContentEquals(byteArrayOf(4), row.latestPin) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `observeById emits null for a chat this device has never stored`() = runTest { dao.observeById(CHAT_HEX).test { 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 43533b0812..bf69f3b11d 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 @@ -11,6 +11,7 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.PinSummary import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -132,8 +133,25 @@ class ChatMetadataDataSource @Inject constructor( /** The hex a [chatId] is keyed by. Callers comparing against [getChatIdsOfType] need it. */ fun chatIdHex(chatId: ChatId): String = mapper.chatIdHex(chatId) + /** The pin summary stored for [chatId]; null when the chat is not stored or never had a pin. */ + suspend fun getPinSummary(chatId: ChatId): PinSummary? = + db?.chatMetadataDao()?.getById(mapper.chatIdHex(chatId))?.let { mapper.toMetadata(it, emptyList(), null).pinSummary } + + /** + * Applies a `PinsChanged` summary only if its version is strictly newer than the stored one + * (none stored = 0). False when it was stale, or the chat is not stored yet. + */ + suspend fun updatePinSummary(chatId: ChatId, summary: PinSummary): Boolean = + db?.chatMetadataDao()?.applyPinsChanged( + chatIdHex = mapper.chatIdHex(chatId), + count = summary.count, + version = summary.version, + latestPin = summary.latestEncoded, + ).let { it != null && it > 0 } + suspend fun upsert(metadata: ChatMetadata) { - db?.chatMetadataDao()?.upsert(mapper.toEntity(metadata)) + val dao = db?.chatMetadataDao() ?: return + dao.upsert(mapper.toEntity(metadata)) } /** @@ -147,7 +165,8 @@ class ChatMetadataDataSource @Inject constructor( } suspend fun upsert(metadatas: List) { - db?.chatMetadataDao()?.upsert(metadatas.map { mapper.toEntity(it) }) + val dao = db?.chatMetadataDao() ?: return + dao.upsert(metadatas.map { mapper.toEntity(it) }) } suspend fun getLastActivity(chatId: ChatId): Long? = 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 78a86e1a0e..f414dd9ca9 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 @@ -40,7 +40,9 @@ import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.PinSummary import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.fromStored import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ClientMessageId import com.getcode.opencode.model.core.ID @@ -107,6 +109,9 @@ class ChatEntityMapper @Inject constructor() { creatorHex = metadata.creator?.hexEncodedString(), useE2ee = metadata.useE2ee, isPrivate = metadata.isPrivate, + pinCount = metadata.pinSummary?.count ?: 0, + pinVersion = metadata.pinSummary?.version ?: 0, + latestPin = metadata.pinSummary?.latestEncoded, ) } @@ -141,9 +146,15 @@ class ChatEntityMapper @Inject constructor() { creator = entity.creatorHex?.hexToIdExt(), useE2ee = entity.useE2ee, isPrivate = entity.isPrivate, + pinSummary = entity.toPinSummary(), ) } + /** The pin summary stored on the row; null for a chat that has never had a pin. */ + private fun ChatMetadataEntity.toPinSummary(): PinSummary? = + if (pinVersion == 0L && pinCount == 0 && latestPin == null) null + else PinSummary.fromStored(pinCount, pinVersion, latestPin) + /** The `mute_until_epoch_ms` column a [mute] writes; null for forever and for no mute. */ fun muteUntilEpochMs(mute: MuteState?): Long? = (mute as? MuteState.Until)?.until?.toEpochMilliseconds() diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 7c45d0610b..366ce64668 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -16,6 +16,7 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.PinSummary import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.SilenceState @@ -49,6 +50,17 @@ class ChatEntityMapperTest { latestEventSequence = latestEventSequence, ) + @Test + fun `a row that never had a pin maps to no pin summary, and one with a version maps to it`() { + val none = ChatMetadataEntity( + chatIdHex = "ab", chatType = "GROUP", lastActivityEpochMs = 1, lastMessageId = null, + ) + assertNull(mapper.toMetadata(none, emptyList(), null).pinSummary) + + val unpinned = mapper.toMetadata(none.copy(pinVersion = 3), emptyList(), null).pinSummary + assertEquals(PinSummary(count = 0, version = 3), unpinned) + } + @Test fun `server head is not written as the applied cursor`() { val entity = mapper.toEntity(metadata(latestEventSequence = 12)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 020450e907..03ef04ed1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,7 +68,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.9.0" -flipcash2-client-protocol = "0.23.0" +flipcash2-client-protocol = "0.24.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 8a7d7af54d..485bb5908f 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 @@ -20,6 +20,8 @@ import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.services.models.chat.PinsPage import com.flipcash.services.models.chat.RestrictedUsersPage import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.MentionSuggestion @@ -266,6 +268,34 @@ class ChatController @Inject constructor( return repository.getRestrictedUsers(owner, chatId, queryOptions) } + /** Pins [messageId] in [chatId]; returns the chat's updated pin summary. */ + suspend fun pinMessage(chatId: ChatId, messageId: Long): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.pinMessage(owner, chatId, messageId) + } + + /** Unpins [messageId] in [chatId]; returns the chat's updated pin summary. */ + suspend fun unpinMessage(chatId: ChatId, messageId: Long): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.unpinMessage(owner, chatId, messageId) + } + + /** One page of [chatId]'s pins, most recently pinned first. */ + suspend fun getPinnedMessages( + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + viewMode: ViewMode = ViewMode.FULL, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getPinnedMessages(owner, chatId, queryOptions, viewMode) + } + /** Stores the caller's own key envelope for [chatId]. */ suspend fun setKeyEnvelope(chatId: ChatId, keyEnvelope: KeyEnvelope): 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 f0def1afe6..cdcda86b09 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 @@ -10,6 +10,7 @@ import com.flipcash.services.internal.network.extensions.toHoldingsSummary import com.flipcash.services.internal.network.extensions.toId import com.flipcash.services.internal.network.extensions.toMediaItem import com.flipcash.services.internal.network.extensions.toPointer +import com.flipcash.services.internal.network.extensions.toPinSummary import com.flipcash.services.internal.network.extensions.toRosterSummary import com.flipcash.services.internal.network.extensions.toViewerState import com.flipcash.services.models.chat.ChatMember @@ -59,6 +60,7 @@ class ChatMetadataMapper @Inject constructor( isPrivate = from.isPrivate, inLobby = from.inLobby, holdingsSummary = if (from.hasHoldingsSummary()) from.holdingsSummary.toHoldingsSummary() else null, + pinSummary = if (from.hasPinSummary()) from.pinSummary.toPinSummary() 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 2ec91ffa47..04622eb45b 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 @@ -20,6 +20,7 @@ import com.flipcash.services.internal.network.extensions.asProtoInviteToken import com.flipcash.services.internal.network.extensions.asTimestamp import com.flipcash.services.internal.network.extensions.asUserId import com.flipcash.services.internal.network.extensions.asUsername +import com.flipcash.services.internal.network.extensions.asMessageId import com.flipcash.services.internal.network.extensions.asViewMode import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions @@ -577,6 +578,62 @@ internal class ChatApi @Inject constructor( } } + suspend fun pinMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): RpcChatService.PinMessageResponse { + val request = RpcChatService.PinMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.pinMessage(request) + } + } + + suspend fun unpinMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): RpcChatService.UnpinMessageResponse { + val request = RpcChatService.UnpinMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.unpinMessage(request) + } + } + + suspend fun getPinnedMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + viewMode: ViewMode = ViewMode.FULL, + ): RpcChatService.GetPinnedMessagesResponse { + val request = RpcChatService.GetPinnedMessagesRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setQueryOptions(queryOptions.asQueryOptions()) + .setViewMode(viewMode.asViewMode()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getPinnedMessages(request) + } + } + suspend fun setKeyEnvelope( owner: KeyPair, chatId: ChatId, 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 4f3e34ad18..1dc827ec6e 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 @@ -52,6 +52,9 @@ import com.flipcash.services.models.chat.isSystemBlank import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.chat.PinChange +import com.flipcash.services.models.chat.PinSummary import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.BanState @@ -461,6 +464,16 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate( MetadataUpdate.CoverPictureChanged(coverPictureChanged.newCoverPicture.toMediaItem()) ChatModel.MetadataUpdate.KindCase.RULES_CHANGED -> MetadataUpdate.RulesChanged(rulesChanged.newRules.toChatRules()) + ChatModel.MetadataUpdate.KindCase.PINS_CHANGED -> MetadataUpdate.PinsChanged( + change = when (pinsChanged.changeCase) { + ChatModel.MetadataUpdate.PinsChanged.ChangeCase.PINNED -> + PinChange.Pinned(pinsChanged.pinned.toPin()) + ChatModel.MetadataUpdate.PinsChanged.ChangeCase.UNPINNED -> + PinChange.Unpinned(pinsChanged.unpinned.value) + else -> null + }, + summary = pinsChanged.summary.toPinSummary(), + ) else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) } } @@ -500,9 +513,26 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { isPrivate = isPrivate, inLobby = inLobby, holdingsSummary = if (hasHoldingsSummary()) holdingsSummary.toHoldingsSummary() else null, + pinSummary = if (hasPinSummary()) pinSummary.toPinSummary() else null, ) } +// -- Pins -- + +internal fun ChatModel.Pin.toPin(): Pin = Pin( + messageId = messageId.value, + pinnedBy = pinnedBy.toId(), + pinnedAt = Instant.fromEpochSeconds(pinnedAt.seconds, pinnedAt.nanos), + message = message.toChatMessage(), +) + +internal fun ChatModel.PinSummary.toPinSummary(): PinSummary = PinSummary( + latest = if (hasLatest()) latest.toPin() else null, + count = count, + version = version, + latestEncoded = if (hasLatest()) latest.toByteArray() else null, +) + internal fun ChatModel.HoldingsSummary.toHoldingsSummary(): HoldingsSummary { return HoldingsSummary( mint = mint.toMint(), @@ -522,6 +552,7 @@ internal fun ChatModel.ViewerState.toViewerState(): ViewerState { ViewerState.Permissions( canEdit = permissions.canEdit, canInvite = permissions.canInvite, + canPin = permissions.canPin, moderation = if (permissions.hasModeration()) { ViewerState.ModerationPermissions( canSilence = permissions.moderation.canSilence, 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 ba48b5fc73..3422bcc26c 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 @@ -6,7 +6,10 @@ import com.flipcash.services.internal.network.api.ChatApi import com.flipcash.services.internal.network.extensions.toFlaggedCategory import com.flipcash.services.models.AdmitLobbyMemberError import com.flipcash.services.models.BanUserError +import com.flipcash.services.models.GetPinnedMessagesError import com.flipcash.services.models.GetRestrictedUsersError +import com.flipcash.services.models.PinMessageError +import com.flipcash.services.models.UnpinMessageError import com.flipcash.services.models.SilenceUserError import com.flipcash.services.models.UnbanUserError import com.flipcash.services.models.UnsilenceUserError @@ -609,6 +612,79 @@ internal class ChatService @Inject constructor( ) } + suspend fun pinMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result { + return runCatching { + api.pinMessage(owner, chatId, messageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.PinMessageResponse.Result.OK -> Result.success(response) + RpcChatService.PinMessageResponse.Result.DENIED -> Result.failure(PinMessageError.Denied()) + RpcChatService.PinMessageResponse.Result.NOT_FOUND -> Result.failure(PinMessageError.NotFound()) + RpcChatService.PinMessageResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(PinMessageError.MessageNotFound()) + RpcChatService.PinMessageResponse.Result.TOO_MANY_PINS -> Result.failure(PinMessageError.TooManyPins()) + RpcChatService.PinMessageResponse.Result.MESSAGE_NOT_ALLOWED -> Result.failure(PinMessageError.MessageNotAllowed()) + RpcChatService.PinMessageResponse.Result.UNRECOGNIZED -> Result.failure(PinMessageError.Unrecognized()) + else -> Result.failure(PinMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { PinMessageError.Other(cause = it) }) + } + ) + } + + suspend fun unpinMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result { + return runCatching { + api.unpinMessage(owner, chatId, messageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.UnpinMessageResponse.Result.OK -> Result.success(response) + RpcChatService.UnpinMessageResponse.Result.DENIED -> Result.failure(UnpinMessageError.Denied()) + RpcChatService.UnpinMessageResponse.Result.NOT_FOUND -> Result.failure(UnpinMessageError.NotFound()) + RpcChatService.UnpinMessageResponse.Result.UNRECOGNIZED -> Result.failure(UnpinMessageError.Unrecognized()) + else -> Result.failure(UnpinMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { UnpinMessageError.Other(cause = it) }) + } + ) + } + + suspend fun getPinnedMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + viewMode: ViewMode, + ): Result { + return runCatching { + api.getPinnedMessages(owner, chatId, queryOptions, viewMode) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetPinnedMessagesResponse.Result.OK -> Result.success(response) + RpcChatService.GetPinnedMessagesResponse.Result.DENIED -> Result.failure(GetPinnedMessagesError.Denied()) + RpcChatService.GetPinnedMessagesResponse.Result.NOT_FOUND -> Result.failure(GetPinnedMessagesError.NotFound()) + RpcChatService.GetPinnedMessagesResponse.Result.UNRECOGNIZED -> Result.failure(GetPinnedMessagesError.Unrecognized()) + else -> Result.failure(GetPinnedMessagesError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetPinnedMessagesError.Other(cause = it) }) + } + ) + } + suspend fun denyLobbyMember( owner: KeyPair, chatId: ChatId, 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 11dedb6793..f26b9c0efb 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 @@ -12,6 +12,8 @@ import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.internal.network.extensions.toLobbyMember import com.flipcash.services.internal.network.extensions.toRestrictedUser import com.flipcash.services.internal.network.extensions.toPagingToken +import com.flipcash.services.internal.network.extensions.toPin +import com.flipcash.services.internal.network.extensions.toPinSummary import com.flipcash.services.internal.network.extensions.toRosterSummary import com.flipcash.services.internal.network.extensions.toViewerState import com.flipcash.services.internal.network.services.ChatService @@ -44,6 +46,8 @@ import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.services.models.chat.PinsPage import com.flipcash.services.models.chat.RestrictedUsersPage import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.MentionSuggestion @@ -286,6 +290,32 @@ internal class InternalChatRepository( ) } + override suspend fun pinMessage(owner: KeyPair, chatId: ChatId, messageId: Long): Result = + service.pinMessage(owner, chatId, messageId) + .onFailure { ErrorUtils.handleError(it) } + .map { if (it.hasPinSummary()) it.pinSummary.toPinSummary() else PinSummary() } + + override suspend fun unpinMessage(owner: KeyPair, chatId: ChatId, messageId: Long): Result = + service.unpinMessage(owner, chatId, messageId) + .onFailure { ErrorUtils.handleError(it) } + .map { if (it.hasPinSummary()) it.pinSummary.toPinSummary() else PinSummary() } + + override suspend fun getPinnedMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + viewMode: ViewMode, + ): Result = service.getPinnedMessages(owner, chatId, queryOptions, viewMode) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + PinsPage( + pins = response.pinsList.map { it.toPin() }, + pinSummary = if (response.hasPinSummary()) response.pinSummary.toPinSummary() else PinSummary(), + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } + override suspend fun setKeyEnvelope( 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 eec9900775..ba70bbbdf0 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 @@ -1181,3 +1181,38 @@ sealed class RevokeInviteError( class Unrecognized : RevokeInviteError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : RevokeInviteError(message = cause?.message, cause = cause), NotifiableError } + +sealed class PinMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : PinMessageError("Denied") + class NotFound : PinMessageError("Not found") + class MessageNotFound : PinMessageError("Message not found") + // The chat is at the server's pin cap. The cap is server-defined; do not hardcode it. + class TooManyPins : PinMessageError("Too many pins") + // The message is of a kind that cannot be pinned. + class MessageNotAllowed : PinMessageError("Message not allowed") + class Unrecognized : PinMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : PinMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class UnpinMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : UnpinMessageError("Denied") + class NotFound : UnpinMessageError("Not found") + class Unrecognized : UnpinMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : UnpinMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetPinnedMessagesError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetPinnedMessagesError("Denied") + class NotFound : GetPinnedMessagesError("Not found") + class Unrecognized : GetPinnedMessagesError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetPinnedMessagesError(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 541e4e9e4c..501b316148 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 @@ -42,4 +42,7 @@ data class ChatMetadata( // public groups gated on a single Flipcash-launched currency above a server-side minimum // holder count. No change events: refresh by refetching the chat. val holdingsSummary: HoldingsSummary? = null, + // Pins summary. Null when unset on the wire; feed/list RPCs may leave it unset even when + // pins exist, so an unset value must never clear a summary already held -- fetch with GetChat. + val pinSummary: PinSummary? = 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 db33168347..f1442a3465 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 @@ -21,4 +21,7 @@ sealed interface MetadataUpdate { // A group chat's rules were edited; replaces the cached rules wholesale. Best-effort // delivery, same caveat as TitleChanged. data class RulesChanged(val newRules: ChatRules) : MetadataUpdate + // A message was pinned or unpinned. [summary] is always present and compared by + // PinSummary.version (keep the greater). [change] is null if the server sent none. + data class PinsChanged(val change: PinChange?, val summary: PinSummary) : MetadataUpdate } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Pin.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Pin.kt new file mode 100644 index 0000000000..8aa5d690c8 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Pin.kt @@ -0,0 +1,71 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.PagingToken +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/** + * A message pinned in a chat. Mirrors `chat.v1.Pin`. + * + * [message] is always set and may be redacted depending on the view mode of the read. A message + * deleted after it was pinned stays pinned as a tombstone; clients should not show it. + */ +data class Pin( + val messageId: Long, + val pinnedBy: ID, + val pinnedAt: Instant, + val message: ChatMessage, +) + +/** + * A chat's pins, described without listing them. Mirrors `chat.v1.PinSummary`. + */ +class PinSummary( + // The most recently pinned message; null when nothing is pinned. + val latest: Pin? = null, + // Number of pinned messages. The cap is server-defined and must not be hardcoded. + val count: Int = 0, + // Advanced by exactly one on every pin or unpin. Compared like RosterSummary.version: + // apply the greater value, drop the rest. + val version: Long = 0, + // `latest` as the serialized `chat.v1.Pin`, set when this summary was read off the wire or + // out of storage. It is what persistence stores: the pinned message may not be in the local + // messages table, so the whole pin is kept as the server sent it. Derived from [latest], so + // it takes no part in equality; a summary built by hand has none and stores no latest pin. + val latestEncoded: ByteArray? = null, +) { + fun copy( + latest: Pin? = this.latest, + count: Int = this.count, + version: Long = this.version, + ): PinSummary = PinSummary( + latest = latest, + count = count, + version = version, + latestEncoded = latestEncoded.takeIf { latest === this.latest }, + ) + + override fun equals(other: Any?): Boolean = + this === other || (other is PinSummary && + latest == other.latest && count == other.count && version == other.version) + + override fun hashCode(): Int = 31 * (31 * count + version.hashCode()) + (latest?.hashCode() ?: 0) + + override fun toString(): String = "PinSummary(latest=$latest, count=$count, version=$version)" + + companion object +} + +/** The pin that a `MetadataUpdate.PinsChanged` carries: a message pinned or unpinned. */ +sealed interface PinChange { + data class Pinned(val pin: Pin) : PinChange + data class Unpinned(val messageId: Long) : PinChange +} + +/** One page of `Chat.GetPinnedMessages`, most recently pinned first. */ +data class PinsPage( + val pins: List, + val pinSummary: PinSummary, + val pagingToken: PagingToken?, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/PinSummaryStorage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/PinSummaryStorage.kt new file mode 100644 index 0000000000..c35adc1f7a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/PinSummaryStorage.kt @@ -0,0 +1,26 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.internal.network.extensions.toPin +import com.google.protobuf.InvalidProtocolBufferException +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel + +/** + * Rebuilds a [PinSummary] from the columns persistence stores for it. [latestEncoded] is the + * serialized `chat.v1.Pin` a summary carried in [PinSummary.latestEncoded]; bytes that no longer + * parse read as no latest pin rather than failing the whole chat. + */ +fun PinSummary.Companion.fromStored(count: Int, version: Long, latestEncoded: ByteArray?): PinSummary { + val pin = latestEncoded?.let { + try { + ChatModel.Pin.parseFrom(it) + } catch (_: InvalidProtocolBufferException) { + null + } + } + return PinSummary( + latest = pin?.toPin(), + count = count, + version = version, + latestEncoded = latestEncoded.takeIf { pin != null }, + ) +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt index 0ab9f7bdb0..96b3ac986a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt @@ -23,6 +23,8 @@ data class ViewerState( // Whether the viewer may create, list and revoke invite links. True today only for a // private group's creator while a member. val canInvite: Boolean = false, + // Whether the viewer may call PinMessage / UnpinMessage on this chat. + val canPin: Boolean = false, // Which moderation actions the viewer may take on other users. val moderation: ModerationPermissions = ModerationPermissions(), ) 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 5623e0d127..f3fa54c23a 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 @@ -22,6 +22,8 @@ import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.services.models.chat.PinsPage import com.flipcash.services.models.chat.RestrictedUsersPage import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.MentionSuggestion @@ -223,6 +225,23 @@ interface ChatRepository { queryOptions: QueryOptions = QueryOptions(), ): Result + /** + * Pins [messageId] in [chatId]. Requires `ViewerState.Permissions.canPin`. Returns the chat's + * updated summary. The pin cap is server-defined: [PinMessageError.TooManyPins] reports it. + */ + suspend fun pinMessage(owner: KeyPair, chatId: ChatId, messageId: Long): Result + + /** Unpins [messageId] in [chatId]. Unpinning a message that is not pinned succeeds. */ + suspend fun unpinMessage(owner: KeyPair, chatId: ChatId, messageId: Long): Result + + /** One page of [chatId]'s pins, most recently pinned first. */ + suspend fun getPinnedMessages( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + viewMode: ViewMode = ViewMode.FULL, + ): Result + /** * Stores the caller's own key envelope for [chatId]. [SetKeyEnvelopeError.AlreadySet] means a * different envelope already stands; it is surfaced rather than recovered because whether to 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 562847ca6f..d4b697f673 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 @@ -22,6 +22,8 @@ import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.StoredKeyEnvelope import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.services.models.chat.PinsPage import com.flipcash.services.models.chat.RestrictedUsersPage import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.RosterPage @@ -682,6 +684,19 @@ private class FakeChatRepository : ChatRepository { queryOptions: QueryOptions, ): Result = Result.success(RestrictedUsersPage(emptyList(), null, false)) + override suspend fun pinMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result = + Result.success(PinSummary()) + + override suspend fun unpinMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result = + Result.success(PinSummary()) + + override suspend fun getPinnedMessages( + owner: Ed25519.KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + viewMode: ViewMode, + ): Result = Result.success(PinsPage(emptyList(), PinSummary(), null, false)) + override suspend fun denyLobbyMember(owner: Ed25519.KeyPair, chatId: ChatId, userId: ID): Result { lastChatId = chatId return denyLobbyMemberResult diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PinMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PinMappingTest.kt new file mode 100644 index 0000000000..c68c0899c6 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PinMappingTest.kt @@ -0,0 +1,153 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.models.chat.PinChange +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.services.models.chat.fromStored +import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PinMappingTest { + + private fun messageId(value: Long): MessagingModel.MessageId = + MessagingModel.MessageId.newBuilder().setValue(value).build() + + private fun pin(id: Long): ChatModel.Pin = ChatModel.Pin.newBuilder() + .setMessageId(messageId(id)) + .setPinnedBy( + Common.UserId.newBuilder().setValue(ByteString.copyFrom(ByteArray(16) { 4 })) + ) + .setPinnedAt(Timestamp.newBuilder().setSeconds(3000).setNanos(5)) + .setMessage(MessagingModel.Message.newBuilder().setMessageId(messageId(id))) + .build() + + private fun summary(latest: Long?, count: Int, version: Long): ChatModel.PinSummary = + ChatModel.PinSummary.newBuilder() + .apply { if (latest != null) setLatest(pin(latest)) } + .setCount(count) + .setVersion(version) + .build() + + @Test + fun `pin maps its message id, pinner, time and message`() { + val result = pin(7).toPin() + + assertEquals(7L, result.messageId) + assertEquals(ByteArray(16) { 4 }.toList(), result.pinnedBy) + assertEquals(3000L, result.pinnedAt.epochSeconds) + assertEquals(7L, result.message.messageId) + } + + @Test + fun `pin summary without a latest pin maps to null latest`() { + val result = summary(latest = null, count = 0, version = 2).toPinSummary() + + assertNull(result.latest) + assertEquals(0, result.count) + assertEquals(2L, result.version) + } + + @Test + fun `a stored pin summary decodes to the summary it was encoded from`() { + val original = summary(latest = 9, count = 3, version = 11).toPinSummary() + val encoded = requireNotNull(original.latestEncoded) + + val restored = PinSummary.fromStored(original.count, original.version, encoded) + + assertEquals(original, restored) + assertEquals(9L, restored.latest?.messageId) + assertContentEquals(encoded, restored.latestEncoded) + } + + @Test + fun `a stored summary with no latest pin, or unreadable bytes, has no latest`() { + assertNull(PinSummary.fromStored(0, 4, null).latest) + val garbage = PinSummary.fromStored(2, 4, byteArrayOf(0x0A, 0x7F, 0x01)) + assertNull(garbage.latest) + assertNull(garbage.latestEncoded) + assertEquals(2, garbage.count) + } + + @Test + fun `pin summary carries latest, count and version`() { + val result = summary(latest = 9, count = 3, version = 11).toPinSummary() + + assertEquals(9L, result.latest?.messageId) + assertEquals(3, result.count) + assertEquals(11L, result.version) + } + + @Test + fun `metadata without a pin summary maps to null`() { + val metadata = ChatModel.Metadata.newBuilder() + .setType(ChatModel.ChatType.GROUP) + .setLastActivity(Timestamp.newBuilder().setSeconds(2000)) + .build() + + assertNull(metadata.toChatMetadata().pinSummary) + } + + @Test + fun `metadata maps its pin summary`() { + val metadata = ChatModel.Metadata.newBuilder() + .setType(ChatModel.ChatType.GROUP) + .setLastActivity(Timestamp.newBuilder().setSeconds(2000)) + .setPinSummary(summary(latest = 1, count = 1, version = 1)) + .build() + + assertEquals(1, metadata.toChatMetadata().pinSummary?.count) + } + + @Test + fun `pins_changed with a pinned change decodes to PinsChanged Pinned`() { + val update = ChatModel.MetadataUpdate.newBuilder() + .setPinsChanged( + ChatModel.MetadataUpdate.PinsChanged.newBuilder() + .setPinned(pin(5)) + .setSummary(summary(latest = 5, count = 1, version = 4)) + ) + .build() + + val result = assertIs(update.toMetadataUpdate { it.toChatMetadata() }) + + assertEquals(5L, assertIs(result.change).pin.messageId) + assertEquals(4L, result.summary.version) + } + + @Test + fun `pins_changed with an unpinned change decodes to PinsChanged Unpinned`() { + val update = ChatModel.MetadataUpdate.newBuilder() + .setPinsChanged( + ChatModel.MetadataUpdate.PinsChanged.newBuilder() + .setUnpinned(messageId(5)) + .setSummary(summary(latest = null, count = 0, version = 5)) + ) + .build() + + val result = assertIs(update.toMetadataUpdate { it.toChatMetadata() }) + + assertEquals(PinChange.Unpinned(5L), result.change) + assertNull(result.summary.latest) + } + + @Test + fun `viewer state maps can_pin`() { + fun viewerState(canPin: Boolean) = ChatModel.ViewerState.newBuilder() + .setPermissions(ChatModel.ViewerState.Permissions.newBuilder().setCanPin(canPin)) + .build() + .toViewerState() + + assertTrue(viewerState(true).permissions.canPin) + assertFalse(viewerState(false).permissions.canPin) + } +}