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 b871482a11..815d330c68 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 @@ -451,6 +451,11 @@ class EventStreamDelegate @Inject constructor( // Same best-effort contract as TitleChanged above. Replaces the cached cover. metadataDataSource.updateCoverPicture(chatId, metaUpdate.newCoverPicture) } + is MetadataUpdate.RulesChanged -> { + // Same best-effort contract as TitleChanged above. The wire carries the full + // replacement rules, so they overwrite the cached ones. + metadataDataSource.updateRules(chatId, metaUpdate.newRules) + } } } @@ -471,6 +476,10 @@ class EventStreamDelegate @Inject constructor( // --- Lobby changes --- + // TODO(flipcash2 0.23.0): ChatUpdate.paymentRequestUpdates arrive here but nothing applies + // them yet. Apply by version per PaymentRequestUpdate's docs (created: keep greater; + // updates: ignore <= held, apply in order at held + 1, refetch beyond or on an unknown + // change) once a payment request store consumes the stream. if (update.lobbyUpdates.isNotEmpty()) { _events.send(Event.LobbyChanged(chatId, update.lobbyUpdates)) } 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 b050d9356d..683efc17d2 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 @@ -341,6 +341,13 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET cover_picture_json = :coverPictureJson WHERE chat_id_hex = :chatIdHex") suspend fun updateCoverPicture(chatIdHex: String, coverPictureJson: MediaItem) + /** + * Unconditional, for the same reason as [updateTitle]: `MetadataUpdate.RulesChanged` carries no + * version. The wire carries the full replacement rules, so this overwrites the column. + */ + @Query("UPDATE chat_metadata SET rules_json = :rulesJson WHERE chat_id_hex = :chatIdHex") + suspend fun updateRules(chatIdHex: String, rulesJson: ChatRulesSerialized?) + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } 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 2db45e47a3..7068d70594 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 @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage 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.ViewerState @@ -214,6 +215,11 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.updateCoverPicture(mapper.chatIdHex(chatId), coverPicture) } + /** Applies a `MetadataUpdate.RulesChanged` for [chatId]. See ChatMetadataDao.updateRules. */ + suspend fun updateRules(chatId: ChatId, rules: ChatRules) { + db?.chatMetadataDao()?.updateRules(mapper.chatIdHex(chatId), mapper.rulesSerialized(rules)) + } + suspend fun getAnalyticsCountedThrough(chatId: ChatId): Long = db?.chatMetadataDao()?.getAnalyticsCountedThrough(mapper.chatIdHex(chatId)) ?: 0L 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 6fe287176f..4f883736da 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 @@ -313,6 +313,8 @@ class ChatEntityMapper @Inject constructor() { // region Helpers + fun rulesSerialized(rules: ChatRules): ChatRulesSerialized = rules.toSerialized() + fun chatIdHex(chatId: ChatId): String = chatId.bytes.toList().hexEncodedString() fun chatIdFromHex(hex: String): ChatId = ChatId(hex.hexToByteArray()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27ae2741f2..1bd4cf1804 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,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.22.0" +flipcash2-client-protocol = "0.23.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/PushController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PushController.kt index 2770a8b4f5..3cfa63bfe9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PushController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PushController.kt @@ -18,6 +18,12 @@ class PushController @Inject constructor( return repository.addToken(owner, token, installationId) } + suspend fun sendTestPush(): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.sendTestPush(owner) + } + suspend fun deleteTokens(): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt index 3ffbdc8167..812e12d947 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt @@ -14,6 +14,7 @@ import com.flipcash.services.internal.network.extensions.toMint import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.chat.Emoji import com.flipcash.services.internal.network.extensions.toChatId @@ -55,6 +56,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( Model.DirectlySentCryptoNotificationMetadata.DestinationIdentifierCase.USER_ID ) meta.userId.value.toByteArray().toList() else null, boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, + requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.RECEIVED_CRYPTO -> { @@ -67,6 +69,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( Model.ReceivedCryptoNotificationMetadata.SourceIdentifierCase.USER_ID ) meta.userId.value.toByteArray().toList() else null, boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, + requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.WITHDREW_CRYPTO -> NotificationMetadata.WithdrewCrypto( @@ -167,6 +170,11 @@ private fun Model.SwapState.toDomain(): SwapState = when (this) { Model.SwapState.UNRECOGNIZED, null -> SwapState.UNKNOWN } +private fun Model.RequestedPayment.toDomain(): RequestedPayment = RequestedPayment( + chatId = chatId.toChatId(), + paymentRequestId = paymentRequestId.value.toByteArray().toList(), +) + private fun Model.BoostedReaction.toDomain(): BoostedReaction = BoostedReaction( chatId = chatId.toChatId(), messageId = messageId.value, 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 f571d10b58..2ec91ffa47 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 @@ -308,6 +308,7 @@ internal class ChatApi @Inject constructor( ) ) } + parameters.rules?.let { setRules(it.asProtoRules()) } parameters.coverPicture?.let { setCoverPicture( RpcChatService.EditChatRequest.CoverPicture.newBuilder() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt index 3ee2494b5b..bbafd0195f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PushApi.kt @@ -50,6 +50,22 @@ internal class PushApi @Inject constructor( } } + /** + * Sends a fixed test notification to every device the caller has registered. + */ + suspend fun sendTestPush(owner: KeyPair): PushService.SendTestPushResponse { + val request = + PushService.SendTestPushRequest.newBuilder() + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.sendTestPush(request) + } + } + /** * Removes all push tokens within an app install for a user */ 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 df78ae12c9..4f3e34ad18 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,7 @@ 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.paymentrequest.PaymentRequestUpdate import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.BanState import com.flipcash.services.models.chat.RestrictedUser @@ -458,6 +459,8 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate( MetadataUpdate.PictureChanged(profilePictureChanged.newProfilePicture.toMediaItem()) ChatModel.MetadataUpdate.KindCase.COVER_PICTURE_CHANGED -> MetadataUpdate.CoverPictureChanged(coverPictureChanged.newCoverPicture.toMediaItem()) + ChatModel.MetadataUpdate.KindCase.RULES_CHANGED -> + MetadataUpdate.RulesChanged(rulesChanged.newRules.toChatRules()) else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) } } @@ -699,6 +702,9 @@ internal fun EventModel.ChatUpdate.toChatUpdate( lobbyUpdates = if (hasLobbyUpdates()) { lobbyUpdates.lobbyUpdatesList.mapNotNull { it.toLobbyUpdateOrNull() } } else emptyList(), + paymentRequestUpdates = if (hasPaymentRequestUpdates()) { + paymentRequestUpdates.paymentRequestUpdatesList.map { it.toPaymentRequestUpdate() } + } else emptyList(), ) } @@ -863,18 +869,56 @@ internal fun PaymentRequestModel.PaymentRequestMetadata.toPaymentRequest(): Paym id = paymentRequestId.value.toByteArray().toList(), creator = creator.toId(), createdAt = Instant.fromEpochSeconds(createdAt.seconds, createdAt.nanos), - status = when (status) { - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_OPEN -> PaymentRequestStatus.Open - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_COMPLETED -> PaymentRequestStatus.Completed - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_CLOSED -> PaymentRequestStatus.Closed - PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_EXPIRED -> PaymentRequestStatus.Expired - else -> PaymentRequestStatus.Unknown - }, + status = status.toDomain(), closedAt = if (hasClosedAt()) Instant.fromEpochSeconds(closedAt.seconds, closedAt.nanos) else null, version = version, kind = groupChat.takeIf { hasGroupChat() }?.toKind() ?: PaymentRequest.Kind.Unsupported, ) +private fun PaymentRequestModel.PaymentRequestStatus.toDomain(): PaymentRequestStatus = when (this) { + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_OPEN -> PaymentRequestStatus.Open + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_COMPLETED -> PaymentRequestStatus.Completed + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_CLOSED -> PaymentRequestStatus.Closed + PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_EXPIRED -> PaymentRequestStatus.Expired + else -> PaymentRequestStatus.Unknown +} + +internal fun PaymentRequestModel.PaymentRequestUpdate.toPaymentRequestUpdate(): PaymentRequestUpdate = + PaymentRequestUpdate( + paymentRequestId = paymentRequestId.value.toByteArray().toList(), + kind = when (kindCase) { + PaymentRequestModel.PaymentRequestUpdate.KindCase.CREATED -> + PaymentRequestUpdate.Kind.Created(created.toPaymentRequest()) + PaymentRequestModel.PaymentRequestUpdate.KindCase.UPDATES -> + PaymentRequestUpdate.Kind.Updates( + version = updates.version, + changes = updates.updatesList.map { it.toChange() }, + ) + else -> PaymentRequestUpdate.Kind.Unknown + }, + ) + +// An unknown update kind, or a share update with no share, maps to Unknown so the store refetches +// rather than applying a partial change. +private fun PaymentRequestModel.PaymentRequestUpdate.Update.toChange(): PaymentRequestUpdate.Change = + when (kindCase) { + PaymentRequestModel.PaymentRequestUpdate.Update.KindCase.SHARE_PAYMENT_UPDATE -> + sharePaymentUpdate.takeIf { it.hasShare() }?.share?.let { share -> + PaymentRequestUpdate.Change.SharePaid( + userId = share.userId.toId(), + paid = if (share.hasPaid()) share.paid.toPaidShare() else null, + ) + } ?: PaymentRequestUpdate.Change.Unknown + PaymentRequestModel.PaymentRequestUpdate.Update.KindCase.STATUS_UPDATE -> + PaymentRequestUpdate.Change.StatusChanged( + status = statusUpdate.status.toDomain(), + closedAt = if (statusUpdate.hasClosedAt()) { + Instant.fromEpochSeconds(statusUpdate.closedAt.seconds, statusUpdate.closedAt.nanos) + } else null, + ) + else -> PaymentRequestUpdate.Change.Unknown + } + private fun PaymentRequestModel.GroupChatPaymentRequest.toKind(): PaymentRequest.Kind { if (!hasSplit()) return PaymentRequest.Kind.Unsupported // An unknown currency can't be priced or shown; the request renders as unsupported. 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 dbbccd24d1..ba48b5fc73 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 @@ -316,6 +316,8 @@ internal class ChatService @Inject constructor( Result.failure(EditChatError.DescriptionModerated(response.flaggedCategory.toFlaggedCategory())) RpcChatService.EditChatResponse.Result.PROFILE_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.PictureBlobNotAccepted()) RpcChatService.EditChatResponse.Result.COVER_PICTURE_BLOB_NOT_ACCEPTED -> Result.failure(EditChatError.CoverPictureBlobNotAccepted()) + RpcChatService.EditChatResponse.Result.INVALID_RULES -> Result.failure(EditChatError.InvalidRules()) + RpcChatService.EditChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(EditChatError.RulesNotSatisfied()) RpcChatService.EditChatResponse.Result.UNRECOGNIZED -> Result.failure(EditChatError.Unrecognized()) else -> Result.failure(EditChatError.Other()) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt index acefecced7..7a55d4fec4 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PushService.kt @@ -5,6 +5,7 @@ import com.getcode.opencode.utils.toValidationOrElse import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.flipcash.services.models.AddTokenError import com.flipcash.services.models.DeleteTokenError +import com.flipcash.services.models.SendTestPushError import com.getcode.ed25519.Ed25519.KeyPair import javax.inject.Inject import com.codeinc.flipcash.gen.push.v1.PushService as RpcPushService @@ -34,6 +35,23 @@ internal class PushService @Inject constructor( ) } + suspend fun sendTestPush(owner: KeyPair): Result { + return runCatching { + api.sendTestPush(owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcPushService.SendTestPushResponse.Result.OK -> Result.success(Unit) + RpcPushService.SendTestPushResponse.Result.UNRECOGNIZED -> Result.failure(SendTestPushError.Unrecognized()) + else -> Result.failure(SendTestPushError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { SendTestPushError.Other(cause = it) }) + } + ) + } + suspend fun deleteTokens( owner: KeyPair, installationId: String?, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt index 8284b45dc0..2a80947c34 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPushRepository.kt @@ -15,6 +15,10 @@ internal class InternalPushRepository( ): Result = service.addToken(owner, token, installationId) .onFailure { ErrorUtils.handleError(it) } + override suspend fun sendTestPush(owner: Ed25519.KeyPair): Result = + service.sendTestPush(owner) + .onFailure { ErrorUtils.handleError(it) } + override suspend fun deleteTokens( owner: Ed25519.KeyPair, installationId: String? diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt index c1eaad6f41..a4378322fa 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt @@ -61,6 +61,8 @@ sealed interface NotificationMetadata { val phoneNumber: String? = null, val userId: ID? = null, val boostedReaction: BoostedReaction? = null, + // Mutually exclusive with [boostedReaction] (a oneof on the wire). + val requestedPayment: RequestedPayment? = null, ) : NotificationMetadata /** @@ -81,6 +83,8 @@ sealed interface NotificationMetadata { val phoneNumber: String? = null, val userId: ID? = null, val boostedReaction: BoostedReaction? = null, + // Mutually exclusive with [boostedReaction] (a oneof on the wire). + val requestedPayment: RequestedPayment? = null, ) : NotificationMetadata /** @@ -125,6 +129,17 @@ data class BoostedReaction( val emoji: Emoji, ) +/** + * Set on a sent/received payment notification when the payment paid a share of a payment request. + * On a directly-sent payment the payer paid their share to the request's creator; on a received + * one a debtor paid their share of the recipient's request. + */ +@Serializable +data class RequestedPayment( + val chatId: ChatId, + val paymentRequestId: ID, +) + enum class SwapState { UNKNOWN, PENDING, 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 fa4642b171..7c305b767a 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 @@ -64,6 +64,14 @@ sealed class DeleteTokenError( data class Other(override val cause: Throwable? = null) : DeleteTokenError(message = cause?.message, cause = cause), NotifiableError } +sealed class SendTestPushError( + override val message: String? = null, + override val cause: Throwable? = null +) : CodeServerError(message, cause) { + class Unrecognized : SendTestPushError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SendTestPushError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetActivityFeedMessagesError( override val message: String? = null, override val cause: Throwable? = null @@ -520,6 +528,12 @@ sealed class EditChatError( class DescriptionModerated(val category: ModerationResult.FlaggedCategory) : EditChatError("Description flagged: $category") class PictureBlobNotAccepted : EditChatError("Picture blob not accepted") class CoverPictureBlobNotAccepted : EditChatError("Cover picture blob not accepted") + // The replacement rules are malformed, or the chat is not a public group with a listener + // minimum balance. A client bug rather than something the user can fix, so notifiable. + class InvalidRules : EditChatError("Invalid rules"), NotifiableError + // The caller does not meet the rules they are setting. Recoverable by the user, so + // deliberately not a NotifiableError, matching StartChatError.RulesNotSatisfied. + class RulesNotSatisfied : EditChatError("Rules not satisfied") class Unrecognized : EditChatError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EditChatError(message = cause?.message, cause = cause), NotifiableError } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt index 1a80b27ba0..812f34f6dd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -1,5 +1,7 @@ package com.flipcash.services.models.chat +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate + data class ChatUpdate( val chatId: ChatId, val pointerUpdates: List = emptyList(), @@ -12,4 +14,7 @@ data class ChatUpdate( val rosterUpdates: List = emptyList(), // Creator-only, best-effort lobby changes for a private group. Unversioned; see LobbyUpdate. val lobbyUpdates: List = emptyList(), + // Best-effort real-time payment request changes; not part of the gap-detected event log. + // Apply per PaymentRequestUpdate's rules; see its docs. + val paymentRequestUpdates: List = emptyList(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt index 530990999d..12ee57a0d8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EditChatParameters.kt @@ -16,6 +16,10 @@ data class EditChatParameters( val coverPicture: BlobId? = null, // Null leaves the description unchanged; see [DescriptionEdit] for set vs clear. val description: DescriptionEdit? = null, + // The full replacement rules, not a patch. Only valid on a public group that has a listener + // minimum balance; the editable parts are that listener amount and the speaker minimum + // balance requirements. Null leaves the rules unchanged. + val rules: ChatRules? = 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 94e8d710ce..db33168347 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 @@ -18,4 +18,7 @@ sealed interface MetadataUpdate { // A group chat's cover picture was edited; replaces the cached cover. Best-effort delivery, // same caveat as TitleChanged. data class CoverPictureChanged(val newCoverPicture: MediaItem) : 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 } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestUpdate.kt new file mode 100644 index 0000000000..342c1b65b3 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestUpdate.kt @@ -0,0 +1,35 @@ +package com.flipcash.services.models.paymentrequest + +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/** + * A best-effort real-time change to a payment request, from `ChatUpdate.payment_request_updates`. + * Unlike chat events these are not gap-detected, so the store applies them by version: + * + * - [Kind.Created]: treat like a fetched copy and keep the greater version (see [newerOf]). + * - [Kind.Updates] at version V against a held version L: V <= L is ignored; V == L + 1 applies the + * [Kind.Updates.changes] in order and sets the version to V; V > L + 1 refetches the request. + * - A [Change.Unknown] anywhere in a batch, or [Kind.Unknown], refetches rather than guessing. + */ +data class PaymentRequestUpdate( + val paymentRequestId: ID, + val kind: Kind, +) { + sealed interface Kind { + data class Created(val paymentRequest: PaymentRequest) : Kind + data class Updates(val version: Long, val changes: List) : Kind + data object Unknown : Kind + } + + sealed interface Change { + /** [userId]'s share is now [paid]; apply it onto the held share of that user. */ + data class SharePaid(val userId: ID, val paid: PaidShare?) : Change + + /** The request moved to [status] (COMPLETED or CLOSED on the wire), closed at [closedAt]. */ + data class StatusChanged(val status: PaymentRequestStatus, val closedAt: Instant?) : Change + + /** A change kind this build does not know, or a share update with no share. */ + data object Unknown : Change + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt index 229949d355..2d8ce3725b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PushRepository.kt @@ -9,6 +9,9 @@ interface PushRepository { installationId: String? ): Result + /** Sends a fixed test notification to every device the caller has registered. */ + suspend fun sendTestPush(owner: KeyPair): Result + suspend fun deleteTokens( owner: KeyPair, installationId: String?,