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
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ sealed interface ChatListItem {
is MessageContent.Media -> "media"
is MessageContent.Reply -> "reply-message"
is MessageContent.System -> "system-message"
is MessageContent.Widget -> "widget-message"
is MessageContent.Encrypted -> "encrypted-message"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ private fun MessageContent.previewText(
// TODO:
is MessageContent.Media -> null
is MessageContent.System -> null

// TODO: preview text for widgets once the design settles.
is MessageContent.Widget -> null
}

private const val MAX_REPLY_UNWRAP_DEPTH = 4
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ fun MessageContent.linkableText(): String? = when (this) {
is MessageContent.Deleted,
is MessageContent.Media,
is MessageContent.System,
is MessageContent.Widget,
is MessageContent.Encrypted,
-> null
}
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ fun ContentBubble(
// TODO
is MessageContent.Media -> Unit
is MessageContent.System -> Unit
// TODO: render ShareProfile; an unsupported widget variant draws nothing until then.
is MessageContent.Widget -> Unit
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ sealed interface GroupAccess {
// one they left. Everyone else is blocked with nothing to buy, which is the arm
// that gets a disabled button rather than a purchase they cannot make.
ChatRuleRequirement.Staff -> !isStaff
// A speaker-only rule; the server does not send it as a listener rule. If one
// ever appears here nobody satisfies it.
ChatRuleRequirement.Never -> true
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ sealed interface MessageContentSerialized {
val caption: Text?,
) : MessageContentSerialized

// Mirrors MessageContent.Widget. [username] is null for an unsupported widget variant.
@Serializable
@SerialName("widget_share_profile")
data class WidgetShareProfile(val username: String?) : MessageContentSerialized

@Serializable
@SerialName("system")
data class System(val fallbackText: String) : MessageContentSerialized
Expand Down Expand Up @@ -343,4 +348,8 @@ sealed interface ChatRuleRequirementSerialized {
@Serializable
@SerialName("staff")
data object Staff : ChatRuleRequirementSerialized

@Serializable
@SerialName("never")
data object Never : ChatRuleRequirementSerialized
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.Emoji
import com.flipcash.services.models.chat.EmojiReaction
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.WidgetContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.PointerType
Expand Down Expand Up @@ -394,6 +395,9 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
items = items,
caption = caption?.let { MessageContentSerialized.Text(it.text) },
)
is MessageContent.Widget -> MessageContentSerialized.WidgetShareProfile(
username = (widget as? WidgetContent.ShareProfile)?.username,
)
is MessageContent.System -> MessageContentSerialized.System(fallbackText = fallbackText)
is MessageContent.Deleted -> MessageContentSerialized.Deleted(
deletedAt = deletedTs.epochSeconds,
Expand All @@ -407,6 +411,9 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
}

private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
is MessageContentSerialized.WidgetShareProfile -> MessageContent.Widget(
username?.let { WidgetContent.ShareProfile(it) } ?: WidgetContent.Unsupported,
)
is MessageContentSerialized.Text -> MessageContent.Text(text)
is MessageContentSerialized.Cash -> MessageContent.Cash(
intentId = intentId.decodeBase58().toList(),
Expand Down Expand Up @@ -466,6 +473,7 @@ private fun ChatRuleRequirement.toSerialized(): ChatRuleRequirementSerialized =
mints = mints.map { it.base58() },
)
ChatRuleRequirement.Staff -> ChatRuleRequirementSerialized.Staff
ChatRuleRequirement.Never -> ChatRuleRequirementSerialized.Never
}

private fun ChatRulesSerialized.toDomain(): ChatRules = ChatRules(
Expand All @@ -482,6 +490,7 @@ private fun ChatRuleRequirementSerialized.toDomain(): ChatRuleRequirement = when
mints = mints.map { PublicKey(it.decodeBase58().toList()) },
)
ChatRuleRequirementSerialized.Staff -> ChatRuleRequirement.Staff
ChatRuleRequirementSerialized.Never -> ChatRuleRequirement.Never
}

private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) {
Expand Down
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.3"
# 0.3.0 is the first release of either package to ship R8 keep rules for its generated
# messages, which is what lets proguard-rules.pro drop its own.
ocp-client-protocol = "0.6.0"
flipcash2-client-protocol = "0.12.0"
flipcash2-client-protocol = "0.13.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 @@ -89,14 +89,20 @@ class ProfileController @Inject constructor(

suspend fun setDisplayName(
displayName: String,
): Result<Unit> {
): Result<String?> {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))

return repository.setDisplayName(displayName, owner)
// Reflect the change locally so anything observing the profile (e.g. a setup flow
// deciding which steps remain) sees it without waiting for a refresh.
.onSuccess { mergeLocalProfile { it.copy(displayName = displayName) } }
.onSuccess { assigned ->
mergeLocalProfile {
// Use the server-assigned username when it sent one, rather than assuming the
// existing username is unchanged.
it.copy(displayName = displayName, username = assigned ?: it.username)
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.ClientMessageId
import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.WidgetContent
import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.TypingState
Expand Down Expand Up @@ -160,6 +161,18 @@ internal fun MessageContent.asContent(): MessagingModel.Content {
.apply { if (caption != null) setCaption(MessagingModel.TextContent.newBuilder().setText(caption.text)) }
)
.build()
is MessageContent.Widget -> when (val w = widget) {
is WidgetContent.ShareProfile -> MessagingModel.Content.newBuilder()
.setWidget(
MessagingModel.WidgetContent.newBuilder()
.setShareProfile(
MessagingModel.ShareProfileWidget.newBuilder().setUsername(w.username.asUsername())
)
)
.build()
// Received-only: there is nothing to send for a variant this client can't read.
WidgetContent.Unsupported -> MessagingModel.Content.getDefaultInstance()
}
is MessageContent.System -> MessagingModel.Content.newBuilder()
.setSystem(MessagingModel.SystemContent.newBuilder().setFallbackText(fallbackText))
.build()
Expand Down Expand Up @@ -216,7 +229,7 @@ internal fun ChatType.asProtoChatType(): ChatModel.ChatType {
return when (this) {
ChatType.UNKNOWN -> ChatModel.ChatType.UNKNOWN
ChatType.CONTACT_DM -> ChatModel.ChatType.CONTACT_DM
ChatType.TIP_DM -> ChatModel.ChatType.TIP_DM
ChatType.TIP_DM -> ChatModel.ChatType.DM
ChatType.GROUP -> ChatModel.ChatType.GROUP
}
}
Expand Down Expand Up @@ -257,6 +270,7 @@ internal fun ChatRules.asProtoRules(): ChatModel.Rules {
internal fun ChatRuleRequirement.asProtoListenerRules(): ChatModel.ListenerRules {
val builder = ChatModel.ListenerRules.newBuilder()
return when (this) {
ChatRuleRequirement.Never -> error("ListenerRules has no `never` arm; Never is speaker-only")
is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build()
ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build()
}
Expand All @@ -267,6 +281,7 @@ internal fun ChatRuleRequirement.asProtoSpeakerRules(): ChatModel.SpeakerRules {
return when (this) {
is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build()
ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build()
ChatRuleRequirement.Never -> builder.setNever(ChatModel.Never.getDefaultInstance()).build()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import com.flipcash.services.models.chat.ImageMetadata
import com.flipcash.services.models.chat.MediaItem
import com.flipcash.services.models.chat.MediaItemRendition
import com.flipcash.services.models.chat.MessageContent
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.MuteState
Expand Down Expand Up @@ -199,6 +200,14 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent {
nonce = encrypted.nonce.toByteArray(),
ciphertext = encrypted.ciphertext.toByteArray(),
)
// A widget variant this client doesn't know renders as unsupported rather than dropping.
MessagingModel.Content.TypeCase.WIDGET -> MessageContent.Widget(
when (widget.typeCase) {
MessagingModel.WidgetContent.TypeCase.SHARE_PROFILE ->
WidgetContent.ShareProfile(widget.shareProfile.username.value)
else -> WidgetContent.Unsupported
}
)
else -> MessageContent.Text("")
}
}
Expand Down Expand Up @@ -387,7 +396,7 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate(
internal fun ChatModel.ChatType.toChatType(): ChatType {
return when (this) {
ChatModel.ChatType.CONTACT_DM -> ChatType.CONTACT_DM
ChatModel.ChatType.TIP_DM -> ChatType.TIP_DM
ChatModel.ChatType.DM -> ChatType.TIP_DM
ChatModel.ChatType.GROUP -> ChatType.GROUP
else -> ChatType.UNKNOWN
}
Expand Down Expand Up @@ -525,6 +534,7 @@ internal fun ChatModel.SpeakerRules.toRuleRequirementOrNull(): ChatRuleRequireme
return when (kindCase) {
ChatModel.SpeakerRules.KindCase.MINIMUM_BALANCE -> minimumBalance.toRuleRequirement()
ChatModel.SpeakerRules.KindCase.STAFF -> ChatRuleRequirement.Staff
ChatModel.SpeakerRules.KindCase.NEVER -> ChatRuleRequirement.Never
else -> null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ internal class ProfileService @Inject constructor(
suspend fun setDisplayName(
displayName: String,
owner: Ed25519.KeyPair,
): Result<Unit> {
): Result<String?> {
return runCatching {
api.setDisplayName(displayName, owner)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
ProfileService.SetDisplayNameResponse.Result.OK -> Result.success(Unit)
ProfileService.SetDisplayNameResponse.Result.OK ->
// The server may auto-assign a username derived from the display name.
Result.success(if (response.hasUsername()) response.username.value else null)
ProfileService.SetDisplayNameResponse.Result.INVALID_DISPLAY_NAME -> Result.failure(SetDisplayNameError.InvalidDisplayName())
ProfileService.SetDisplayNameResponse.Result.DENIED -> Result.failure(SetDisplayNameError.Denied())
ProfileService.SetDisplayNameResponse.Result.FAILED_MODERATED ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ internal class InternalProfileRepository(
override suspend fun setDisplayName(
displayName: String,
owner: Ed25519.KeyPair
): Result<Unit> {
): Result<String?> {
return service.setDisplayName(displayName, owner)
.onFailure {
// The rejections below are the server answering a user's choice of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,22 @@ fun buildTipDmPaymentMetadata(
.setChat(
FlipcashIntentModel.ChatMetadata.newBuilder()
.setChatId(Common.ChatId.newBuilder().setValue(chatId.bytes.toByteString()))
.setTipDmPayment(
FlipcashIntentModel.ChatMetadata.TipDmPayment.newBuilder()
.setDmPayment(
FlipcashIntentModel.ChatMetadata.DmPayment.newBuilder()
.setLocation(
when (origin) {
TipOrigin.TIPCARD ->
FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.TIPCARD
FlipcashIntentModel.ChatMetadata.DmPayment.Location.FLIPCARD
TipOrigin.CHAT ->
FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.CHAT
FlipcashIntentModel.ChatMetadata.DmPayment.Location.CHAT
}
)
.setAction(
when (action) {
TipAction.SEND ->
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.SEND
FlipcashIntentModel.ChatMetadata.DmPayment.Action.SEND
TipAction.TIP ->
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.TIP
FlipcashIntentModel.ChatMetadata.DmPayment.Action.TIP
}
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ sealed interface ChatRuleRequirement {

/** Requires Flipcash staff membership, as indicated by `UserFlags.is_staff`. */
data object Staff : ChatRuleRequirement

/** Nobody may take the action (`chat.v1.Never`). Only the server sends it, and only as a speaker rule. */
data object Never : ChatRuleRequirement
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ sealed interface MessageContent {
val items: List<MediaItem>,
val caption: Text?,
) : MessageContent
/**
* A structured widget (`messaging.v1.Content.widget`). A widget variant this client does not
* recognise arrives as [WidgetContent.Unsupported] and is rendered as an unsupported message.
*/
data class Widget(val widget: WidgetContent) : MessageContent
data class System(val fallbackText: String) : MessageContent
data class Deleted(
val deletedTs: Instant,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.flipcash.services.models.chat

/** The variant carried by a [MessageContent.Widget]. */
sealed interface WidgetContent {
/** Invites the viewer to open [username]'s profile. */
data class ShareProfile(val username: String) : WidgetContent

/** A `WidgetContent.type` this client does not recognise. */
data object Unsupported : WidgetContent
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import com.getcode.opencode.model.financial.Fiat

interface ProfileRepository {
suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair): Result<UserProfile>
suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result<Unit>
suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result<String?>
suspend fun setUsername(username: String, owner: Ed25519.KeyPair): Result<Unit>
suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result<MediaItem>
suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String): Result<Unit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,35 @@ class ProfileControllerTest {
@Test
fun `setDisplayName delegates to repository`() = runTest {
stubOwner()
repository.setDisplayNameResult = Result.success(Unit)
repository.setDisplayNameResult = Result.success(null)

val result = controller.setDisplayName("Test")

assertTrue(result.isSuccess)
}

@Test
fun `setDisplayName caches the username the server assigned`() = runTest {
stubOwner()
every { userManager.profile } returns stubProfile().copy(username = "old")
repository.setDisplayNameResult = Result.success("assigned")

controller.setDisplayName("Test")

verify { userManager.set(match<UserProfile> { it.displayName == "Test" && it.username == "assigned" }) }
}

@Test
fun `setDisplayName keeps the existing username when the server assigns none`() = runTest {
stubOwner()
every { userManager.profile } returns stubProfile().copy(username = "old")
repository.setDisplayNameResult = Result.success(null)

controller.setDisplayName("Test")

verify { userManager.set(match<UserProfile> { it.displayName == "Test" && it.username == "old" }) }
}

// endregion

// region linkTwitterXAccount
Expand Down Expand Up @@ -302,7 +324,7 @@ class ProfileControllerTest {

private class FakeProfileRepository : ProfileRepository {
var getProfileResult: Result<UserProfile> = Result.failure(RuntimeException("not configured"))
var setDisplayNameResult: Result<Unit> = Result.success(Unit)
var setDisplayNameResult: Result<String?> = Result.success(null)
var setUsernameResult: Result<Unit> = Result.success(Unit)
var setProfilePictureResult: Result<MediaItem> = Result.failure(RuntimeException("not configured"))
var updateTipCardResult: Result<Unit> = Result.success(Unit)
Expand Down
Loading
Loading