From a0183fd8e2dd404948368eb0384d6326272a1027 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 15:38:23 -0400 Subject: [PATCH 1/2] feat(chat): scaffold roster summary, participation rules, and chat-profile blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flipcash2-client-protocol synced to 35f99814 (six commits, #93-#98). Two of its changes are wire-compatible renames that still break the build: EmojiReaction and ReactionUpdate's `sequence` field became `version`, and blob.v1.AccessContext's `profile` oneof arm became `user_profile` — an exhaustive `when` over that oneof stops compiling on the rename alone, before it even reaches the new `chat_profile` arm the same sync adds. The rest is additive. chat.v1.Metadata gains `picture` (a group chat's profile picture), `roster_summary` (member count plus a staleness version, so a client can tell its cached roster went stale without holding the roster itself), and `rules` (participation gates on listening and speaking). Both proto-to-domain mapping paths — ProtobufToLocal's toChatMetadata() and ChatMetadataMapper, which exist in parallel for historical reasons — now populate all three the same way. ListenerRules and SpeakerRules are two proto messages with an identical `kind` oneof (minimum balance or staff), so they collapse into one domain type, ChatRuleRequirement, used by both ChatRules.listener and ChatRules.speaker. A `kind`-unset entry is dropped rather than defaulted: inventing a requirement the server never sent would wrongly gate the chat, and inventing "no requirement" would wrongly open it. Nothing in the app reads picture, rosterSummary, or rules yet, so this is scaffolding, not a feature: the new ChatMetadata fields default to null/zero so the existing test constructors keep compiling, and no screen shows a roster count or a rules gate. That UI work is a separate, open product decision. BlobAccessContext.ChatProfile is wired the same way — mapped to and from the proto, validated, and otherwise unconsumed. --- .../internal/domain/ChatMetadataMapper.kt | 6 ++ .../internal/network/api/BlobStorageApi.kt | 4 +- .../network/extensions/ProtobufToLocal.kt | 55 ++++++++++++++++++- .../services/models/chat/BlobAccessContext.kt | 7 +++ .../services/models/chat/ChatMetadata.kt | 8 +++ .../services/models/chat/ChatRules.kt | 38 +++++++++++++ .../services/models/chat/RosterSummary.kt | 20 +++++++ .../api/BlobAccessContextValidationTest.kt | 11 +++- 8 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/RosterSummary.kt 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 fc5b4ed8f8..c9e2a5b862 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 92a664e084..969dfbeef9 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 c6a67f9dc5..a3c6c2589b 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 046d7c8a51..5a8a407781 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 e191085519..81641372f1 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 0000000000..93491858c0 --- /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 0000000000..29fa26ca0e --- /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 d1a9822c52..46bfc5e29b 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() From 37fd03365bf3dd386baca0c82a7c7eb489ddce12 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 14 Sep 2026 16:04:12 -0400 Subject: [PATCH 2/2] chore(deps): pin flipcash2-client-protocol 0.6.0 0.6.0 is unpublished until flipcash2-client-protocol's sync PR merges and publish.yml runs, so Gradle resolution fails here until then. The scaffold commit ahead of this builds against the local checkout through protoLocalRoot, which CI never sees. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea824bebb3..6671b0329b 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.3.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,