Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -258,7 +261,7 @@ internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction {
count = count,
reactedBySelf = reactedBySelf,
sampleReactors = sampleReactorsList.map { it.toReactor() },
sequence = sequence,
sequence = version,
)
}

Expand All @@ -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),
)
}
Expand Down Expand Up @@ -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() },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<ChatRuleRequirement>,
val speaker: List<ChatRuleRequirement>,
)

/**
* 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<PublicKey>,
) : ChatRuleRequirement

/** Requires Flipcash staff membership, as indicated by `UserFlags.is_staff`. */
data object Staff : ChatRuleRequirement
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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()
Expand Down
Loading