diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54b484488..b12d783dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.2" # 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.4.0" -flipcash2-client-protocol = "0.5.0" +flipcash2-client-protocol = "0.6.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index fc5b4ed8f..c9e2a5b86 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 @@ -4,9 +4,12 @@ import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.flipcash.services.internal.domain.mapper.Mapper import com.flipcash.services.internal.network.extensions.toChatId import com.flipcash.services.internal.network.extensions.toChatMessage +import com.flipcash.services.internal.network.extensions.toChatRules import com.flipcash.services.internal.network.extensions.toChatType 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.toRosterSummary import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import kotlin.time.Instant @@ -36,6 +39,9 @@ class ChatMetadataMapper @Inject constructor( latestEventSequence = from.latestEventSequence, isHidden = from.isHidden, title = from.title.takeIf { it.isNotEmpty() }, + picture = if (from.hasPicture()) from.picture.toMediaItem() else null, + rosterSummary = from.rosterSummary.toRosterSummary(), + rules = if (from.hasRules()) from.rules.toChatRules() else null, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt index 92a664e08..969dfbeef 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/BlobStorageApi.kt @@ -110,8 +110,10 @@ internal class BlobStorageApi @Inject constructor( private fun BlobAccessContext.toProto(): Model.AccessContext? = when (this) { BlobAccessContext.Owned -> null is BlobAccessContext.Profile -> - Model.AccessContext.newBuilder().setProfile(userId.asUserId()).build() + Model.AccessContext.newBuilder().setUserProfile(userId.asUserId()).build() is BlobAccessContext.Chat -> Model.AccessContext.newBuilder().setChat(chatId.asChatId()).build() + is BlobAccessContext.ChatProfile -> + Model.AccessContext.newBuilder().setChatProfile(chatId.asChatId()).build() } } 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 c6a67f9dc..a3c6c2589 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 @@ -45,6 +45,9 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.services.models.chat.Reactor @@ -258,7 +261,7 @@ internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction { count = count, reactedBySelf = reactedBySelf, sampleReactors = sampleReactorsList.map { it.toReactor() }, - sequence = sequence, + sequence = version, ) } @@ -280,7 +283,7 @@ internal fun MessagingModel.ReactionUpdate.toReactionUpdate(): ReactionUpdate { else -> ReactionUpdate.Action.UNKNOWN }, count = count, - sequence = sequence, + sequence = version, reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), ) } @@ -401,6 +404,54 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { latestEventSequence = latestEventSequence, isHidden = isHidden, title = title.takeIf { it.isNotEmpty() }, + picture = if (hasPicture()) picture.toMediaItem() else null, + rosterSummary = rosterSummary.toRosterSummary(), + rules = if (hasRules()) rules.toChatRules() else null, + ) +} + +// -- Chat roster summary -- + +internal fun ChatModel.RosterSummary.toRosterSummary(): RosterSummary { + return RosterSummary( + memberCount = memberCount, + version = version, + ) +} + +// -- Chat participation rules -- + +internal fun ChatModel.Rules.toChatRules(): ChatRules { + return ChatRules( + listener = listenerList.mapNotNull { it.toRuleRequirementOrNull() }, + speaker = speakerList.mapNotNull { it.toRuleRequirementOrNull() }, + ) +} + +// Malformed (kind-not-set) entries are dropped rather than defaulted: fabricating a requirement +// the server never sent would wrongly gate the chat, and inventing "no requirement" would wrongly +// open it. Both ListenerRules and SpeakerRules mark `kind` as validate.required, so the server +// is not expected to send one, but a client should not crash decoding an older/newer wire shape. +internal fun ChatModel.ListenerRules.toRuleRequirementOrNull(): ChatRuleRequirement? { + return when (kindCase) { + ChatModel.ListenerRules.KindCase.MINIMUM_BALANCE -> minimumBalance.toRuleRequirement() + ChatModel.ListenerRules.KindCase.STAFF -> ChatRuleRequirement.Staff + else -> null + } +} + +internal fun ChatModel.SpeakerRules.toRuleRequirementOrNull(): ChatRuleRequirement? { + return when (kindCase) { + ChatModel.SpeakerRules.KindCase.MINIMUM_BALANCE -> minimumBalance.toRuleRequirement() + ChatModel.SpeakerRules.KindCase.STAFF -> ChatRuleRequirement.Staff + else -> null + } +} + +internal fun ChatModel.MinimumBalanceRequirement.toRuleRequirement(): ChatRuleRequirement.MinimumBalance { + return ChatRuleRequirement.MinimumBalance( + amount = amount.toFiat(), + mints = mintsList.map { it.toPublicKey() }, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobAccessContext.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobAccessContext.kt index 046d7c8a5..5a8a40778 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobAccessContext.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/BlobAccessContext.kt @@ -23,6 +23,13 @@ sealed interface BlobAccessContext { /** Read from within [chatId]. Granted iff the caller is a member and the blob was shared into it. */ data class Chat(val chatId: ChatId) : BlobAccessContext + /** + * Read from [chatId]'s public profile. Grants only the renditions of that chat's *current* + * profile picture — a superseded picture stops resolving through it. Distinct from [Chat]: + * this authorizes off the chat's public profile picture, not membership in the chat. + */ + data class ChatProfile(val chatId: ChatId) : BlobAccessContext + companion object { /** * [Profile] for [userId], falling back to [Owned] when the id isn't known. The fallback is 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 e19108551..81641372f 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 @@ -12,4 +12,12 @@ data class ChatMetadata( val isHidden: Boolean = false, // Title for this chat. Only set for group chats. val title: String? = null, + // Picture for this chat. Only set for group chats. + val picture: MediaItem? = null, + // True roster size and staleness version. Server-authoritative; defaults to zero for + // metadata reconstructed without a server round trip. + val rosterSummary: RosterSummary = RosterSummary(memberCount = 0, version = 0), + // Participation requirements for this chat. Only set for group chats; null means the chat + // has no requirements. + val rules: ChatRules? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt new file mode 100644 index 000000000..93491858c --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt @@ -0,0 +1,38 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.PublicKey + +/** + * Requirements a user must satisfy to participate in a chat. Only supported for group chats; + * absent entirely means the chat has no participation requirements. + * + * [listener] and [speaker] are independently optional: [listener] gates reading and joining, + * [speaker] gates sending messages. All rules within a class must be satisfied, and speaker + * rules apply in addition to listener rules — a user must be able to listen before they can + * speak. + */ +data class ChatRules( + val listener: List, + val speaker: List, +) + +/** + * A single requirement gating participation in a chat. + * + * The proto models a listener requirement and a speaker requirement as two separate messages + * (`ListenerRules`, `SpeakerRules`) that share the exact same `kind` oneof shape — a minimum + * balance or staff membership. Nothing distinguishes one from the other beyond which list it + * sits in, so this collapses both into one domain type used by [ChatRules.listener] and + * [ChatRules.speaker] alike. + */ +sealed interface ChatRuleRequirement { + /** Requires holding at least [amount], denominated in fiat, in one of [mints] (all mints when empty). */ + data class MinimumBalance( + val amount: Fiat, + val mints: List, + ) : ChatRuleRequirement + + /** Requires Flipcash staff membership, as indicated by `UserFlags.is_staff`. */ + data object Staff : ChatRuleRequirement +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterSummary.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterSummary.kt new file mode 100644 index 000000000..29fa26ca0 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterSummary.kt @@ -0,0 +1,20 @@ +package com.flipcash.services.models.chat + +/** + * A chat's roster — its member list — described without containing it: what a client needs in + * order to know whether its copy of that list is stale, without holding the list. + * + * Says nothing about member profiles; those are hydrated afresh onto every response that carries + * a member, and a profile change never moves this summary. + */ +data class RosterSummary( + // Number of currently joined members. For a large group chat, ChatMetadata.members is only + // a subset of the roster; this is its true size. + val memberCount: Long, + // Opaque version, advanced by exactly one on every change to the membership records (a join, + // a leave, and in future any per-member change such as a role) — never on an idempotent + // no-op or a profile change. Compare against the last value seen: a different value means the + // cached member list may be stale and should be refetched. There is no delta to fetch against + // it, only a refetch of the members. + val version: Long, +) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/api/BlobAccessContextValidationTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/api/BlobAccessContextValidationTest.kt index d1a9822c5..46bfc5e29 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/api/BlobAccessContextValidationTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/api/BlobAccessContextValidationTest.kt @@ -25,7 +25,7 @@ class BlobAccessContextValidationTest { @Test fun `a profile scope validates`() { val context = Model.AccessContext.newBuilder() - .setProfile(userId().asUserId()) + .setUserProfile(userId().asUserId()) .build() assertEquals(ValidationResult.Valid, context.validate()) @@ -40,6 +40,15 @@ class BlobAccessContextValidationTest { assertEquals(ValidationResult.Valid, context.validate()) } + @Test + fun `a chat profile scope validates`() { + val context = Model.AccessContext.newBuilder() + .setChatProfile(chatId().asChatId()) + .build() + + assertEquals(ValidationResult.Valid, context.validate()) + } + @Test fun `an unset scope does not validate`() { val context = Model.AccessContext.newBuilder().build()