diff --git a/api/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt b/api/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt index 8e38f6dd5..3c265961b 100644 --- a/api/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt +++ b/api/src/androidTest/java/com/getcode/models/intents/IntentPrivateTransferTest.kt @@ -52,7 +52,6 @@ class IntentPrivateTransferTest { val rendezvous = PublicKey.generate() val intent = IntentPrivateTransfer.newInstance( - context = context, rendezvousKey = rendezvous, organizer = organizer, destination = destination, diff --git a/api/src/main/java/com/getcode/model/IntentMetadata.kt b/api/src/main/java/com/getcode/model/IntentMetadata.kt index dc14ba3d8..46f824b90 100644 --- a/api/src/main/java/com/getcode/model/IntentMetadata.kt +++ b/api/src/main/java/com/getcode/model/IntentMetadata.kt @@ -21,6 +21,7 @@ sealed class IntentMetadata { metadata.receivePaymentsPublicly.exchangeData.currency, metadata.receivePaymentsPublicly.exchangeData.quarks, metadata.receivePaymentsPublicly.exchangeData.exchangeRate, + metadata.sendPrivatePayment.isChat, )?.let { ReceivePaymentsPublicly(it) } } TransactionService.Metadata.TypeCase.UPGRADE_PRIVACY -> UpgradePrivacy @@ -30,6 +31,7 @@ sealed class IntentMetadata { metadata.sendPrivatePayment.exchangeData.currency, metadata.sendPrivatePayment.exchangeData.quarks, metadata.sendPrivatePayment.exchangeData.exchangeRate, + metadata.sendPrivatePayment.isChat, )?.let { SendPrivatePayment(it) } } TransactionService.Metadata.TypeCase.SEND_PUBLIC_PAYMENT -> { @@ -37,6 +39,7 @@ sealed class IntentMetadata { metadata.sendPublicPayment.exchangeData.currency, metadata.sendPrivatePayment.exchangeData.quarks, metadata.sendPublicPayment.exchangeData.exchangeRate, + metadata.sendPrivatePayment.isChat, )?.let { SendPublicPayment(it) } } else -> null @@ -47,6 +50,7 @@ sealed class IntentMetadata { currencyString: String, quarks: Long, exchangeRate: Double, + isChat: Boolean, ): PaymentMetadata? { val currency = CurrencyCode.tryValueOf(currencyString.uppercase()) ?: return null @@ -58,12 +62,14 @@ sealed class IntentMetadata { fx = exchangeRate, currency = currency ) - ) + ), + isChat = isChat, ) } } } data class PaymentMetadata( - val amount: KinAmount + val amount: KinAmount, + val isChat: Boolean, ) \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/TipMetadata.kt b/api/src/main/java/com/getcode/model/SocialUser.kt similarity index 78% rename from api/src/main/java/com/getcode/model/TipMetadata.kt rename to api/src/main/java/com/getcode/model/SocialUser.kt index b3fff821f..d3baeadb6 100644 --- a/api/src/main/java/com/getcode/model/TipMetadata.kt +++ b/api/src/main/java/com/getcode/model/SocialUser.kt @@ -5,7 +5,7 @@ import com.getcode.utils.serializer.PublicKeyAsStringSerializer import kotlinx.serialization.Serializable @Serializable -sealed interface TipMetadata { +sealed interface SocialUser { val platform: String val username: String @Serializable(with = PublicKeyAsStringSerializer::class) @@ -13,4 +13,8 @@ sealed interface TipMetadata { val imageUrl: String? val imageUrlSanitized: String? + + val costOfFriendship: Fiat + val isFriend: Boolean + val chatId: ID } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/TwitterUser.kt b/api/src/main/java/com/getcode/model/TwitterUser.kt index bc9f059b1..9461ec901 100644 --- a/api/src/main/java/com/getcode/model/TwitterUser.kt +++ b/api/src/main/java/com/getcode/model/TwitterUser.kt @@ -2,16 +2,11 @@ package com.getcode.model import android.webkit.MimeTypeMap import com.codeinc.gen.user.v1.IdentityService +import com.codeinc.gen.user.v1.friendChatIdOrNull +import com.codeinc.gen.user.v1.friendshipCostOrNull import com.getcode.solana.keys.PublicKey -import com.getcode.solana.keys.base58 import com.getcode.utils.serializer.PublicKeyAsStringSerializer -import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder @Serializable data class TwitterUser( @@ -22,9 +17,10 @@ data class TwitterUser( val displayName: String, val followerCount: Int, val verificationStatus: VerificationStatus, - val costOfFriendship: Fiat, - val isFriend: Boolean, -): TipMetadata { + override val costOfFriendship: Fiat, + override val isFriend: Boolean, + override val chatId: ID, +): SocialUser { override val platform: String = "X" @@ -55,8 +51,12 @@ data class TwitterUser( followerCount = proto.followerCount, tipAddress = tipAddress, verificationStatus = VerificationStatus.entries.getOrNull(proto.verifiedTypeValue) ?: VerificationStatus.unknown, - costOfFriendship = Fiat(currency = CurrencyCode.USD, amount = 1.00), - isFriend = proto.isFriend + costOfFriendship = proto.friendshipCostOrNull?.let { + val currency = CurrencyCode.tryValueOf(it.currency) ?: return@let null + Fiat(currency, it.nativeAmount) + } ?: Fiat(currency = CurrencyCode.USD, amount = 1.00), + isFriend = proto.isFriend, + chatId = proto.friendChatId.value.toList() ) } } diff --git a/api/src/main/java/com/getcode/model/chat/Chats.kt b/api/src/main/java/com/getcode/model/chat/Chats.kt index 4d267eea3..e899dcb96 100644 --- a/api/src/main/java/com/getcode/model/chat/Chats.kt +++ b/api/src/main/java/com/getcode/model/chat/Chats.kt @@ -6,7 +6,7 @@ typealias ChatGrpcV1 = com.codeinc.gen.chat.v1.ChatGrpc typealias ChatGrpcV2 = com.codeinc.gen.chat.v2.ChatGrpc typealias ChatIdV1 = com.codeinc.gen.chat.v1.ChatService.ChatId -typealias ChatIdV2 = com.codeinc.gen.chat.v2.ChatService.ChatId +typealias ChatIdV2 = com.codeinc.gen.common.v1.Model.ChatId typealias MessageContentV1 = com.codeinc.gen.chat.v1.ChatService.Content typealias MessageContentV2 = com.codeinc.gen.chat.v2.ChatService.Content diff --git a/api/src/main/java/com/getcode/model/chat/Platform.kt b/api/src/main/java/com/getcode/model/chat/Platform.kt index d946f0d57..df2fa145a 100644 --- a/api/src/main/java/com/getcode/model/chat/Platform.kt +++ b/api/src/main/java/com/getcode/model/chat/Platform.kt @@ -12,7 +12,11 @@ enum class Platform { } fun named(name: String): Platform { - return entries.firstOrNull { it.name.lowercase() == name.lowercase() } ?: Unknown + val normalizedName = name.lowercase() + return entries.firstOrNull { + it.name.lowercase() == normalizedName || + (normalizedName == "x" && it.name.lowercase() == "twitter") + } ?: Unknown } } } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt b/api/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt index 93987d1aa..938ae6c92 100644 --- a/api/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt +++ b/api/src/main/java/com/getcode/model/intents/IntentPrivateTransfer.kt @@ -1,24 +1,32 @@ package com.getcode.model.intents -import android.content.Context +import com.codeinc.gen.chat.v2.ChatService import com.codeinc.gen.transaction.v2.TransactionService -import com.codeinc.gen.transaction.v2.TransactionService.TippedUser.Platform -import com.getcode.model.TipMetadata import com.getcode.model.Fee +import com.getcode.model.ID import com.getcode.model.Kin import com.getcode.model.KinAmount +import com.getcode.model.SocialUser +import com.getcode.model.chat.ChatIdV2 +import com.getcode.model.chat.Platform import com.getcode.model.intents.actions.ActionFeePayment import com.getcode.model.intents.actions.ActionOpenAccount import com.getcode.model.intents.actions.ActionTransfer import com.getcode.model.intents.actions.ActionWithdraw +import com.getcode.network.repository.toByteString import com.getcode.network.repository.toPublicKey import com.getcode.network.repository.toSolanaAccount -import com.getcode.solana.keys.* +import com.getcode.solana.keys.PublicKey import com.getcode.solana.organizer.AccountType import com.getcode.solana.organizer.Organizer import com.getcode.solana.organizer.Tray import timber.log.Timber +sealed interface PrivateTransferMetadata { + data class Tip(val socialUser: SocialUser): PrivateTransferMetadata + data class Chat(val socialUser: SocialUser): PrivateTransferMetadata +} + class IntentPrivateTransfer( override val id: PublicKey, private val organizer: Organizer, @@ -30,7 +38,7 @@ class IntentPrivateTransfer( private val fee: Kin, private val additionalFees: List, private val isWithdrawal: Boolean, - private val tipMetadata: TipMetadata?, + private val metadata: PrivateTransferMetadata?, val resultTray: Tray, override val actionGroup: ActionGroup, @@ -49,12 +57,24 @@ class IntentPrivateTransfer( .setNativeAmount(grossAmount.fiat) ) - if (tipMetadata != null) { - setIsTip(true) - setTippedUser(TransactionService.TippedUser.newBuilder() - .setPlatformValue(Platform.TWITTER_VALUE) - .setUsername(tipMetadata.username) - ) + when (metadata) { + is PrivateTransferMetadata.Chat -> { + setIsChat(true) + setChatId(ChatIdV2.newBuilder() + .setValue(metadata.socialUser.chatId.toByteString()) + ) + } + is PrivateTransferMetadata.Tip -> { + setIsTip(true) + setTippedUser(TransactionService.TippedUser.newBuilder() + .setPlatformValue(when (Platform.named(metadata.socialUser.platform)) { + Platform.Unknown -> ChatService.Platform.UNKNOWN_PLATFORM_VALUE + Platform.Twitter -> ChatService.Platform.TWITTER_VALUE + }) + .setUsername(metadata.socialUser.username) + ) + } + null -> Unit } } ) @@ -63,7 +83,6 @@ class IntentPrivateTransfer( companion object { fun newInstance( - context: Context, rendezvousKey: PublicKey, organizer: Organizer, destination: PublicKey, @@ -71,7 +90,7 @@ class IntentPrivateTransfer( fee: Kin, additionalFees: List, isWithdrawal: Boolean, - tipMetadata: TipMetadata? + metadata: PrivateTransferMetadata?, ): IntentPrivateTransfer { if (fee > amount.kin) { throw IntentPrivateTransferException.InvalidFeeException() @@ -153,7 +172,7 @@ class IntentPrivateTransfer( kind = ActionWithdraw.Kind.NoPrivacyWithdraw(netAmount.kin), cluster = currentTray.outgoing.getCluster(), destination = destination, - tipMetadata = tipMetadata + metadata = metadata ) // 3. Redistribute the funds to optimize for a @@ -217,7 +236,7 @@ class IntentPrivateTransfer( fee = fee, additionalFees = additionalFees, isWithdrawal = isWithdrawal, - tipMetadata = tipMetadata, + metadata = metadata, actionGroup = group, resultTray = currentTray, ) diff --git a/api/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt b/api/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt index 3d95c992d..a3b05893f 100644 --- a/api/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt +++ b/api/src/main/java/com/getcode/model/intents/actions/ActionWithdraw.kt @@ -2,8 +2,9 @@ package com.getcode.model.intents.actions import com.codeinc.gen.transaction.v2.TransactionService import com.getcode.ed25519.Ed25519 -import com.getcode.model.TipMetadata +import com.getcode.model.SocialUser import com.getcode.model.Kin +import com.getcode.model.intents.PrivateTransferMetadata import com.getcode.model.intents.ServerParameter import com.getcode.network.repository.toPublicKey import com.getcode.network.repository.toSolanaAccount @@ -23,7 +24,7 @@ class ActionWithdraw( val cluster: AccountCluster, val destination: PublicKey, val legacy: Boolean, - val tipMetadata: TipMetadata? = null, + val metadata: PrivateTransferMetadata? = null, ) : ActionType() { override fun transactions(): List { @@ -37,7 +38,7 @@ class ActionWithdraw( recentBlockhash = config.blockhash, kreIndex = kreIndex, legacy = legacy, - tipMetadata = tipMetadata, + metadata = metadata, ) }.orEmpty() } @@ -87,7 +88,7 @@ class ActionWithdraw( cluster: AccountCluster, destination: PublicKey, legacy: Boolean = false, - tipMetadata: TipMetadata? = null, + metadata: PrivateTransferMetadata? = null, ): ActionWithdraw { return ActionWithdraw( id = 0, @@ -97,7 +98,7 @@ class ActionWithdraw( cluster = cluster, destination = destination, legacy = legacy, - tipMetadata = tipMetadata + metadata = metadata ) } diff --git a/api/src/main/java/com/getcode/network/ChatHistoryController.kt b/api/src/main/java/com/getcode/network/ChatHistoryController.kt index 271c511c0..4ac808e31 100644 --- a/api/src/main/java/com/getcode/network/ChatHistoryController.kt +++ b/api/src/main/java/com/getcode/network/ChatHistoryController.kt @@ -11,6 +11,7 @@ import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.manager.SessionManager import com.getcode.mapper.ConversationMapper import com.getcode.mapper.ConversationMessageMapper +import com.getcode.model.Conversation import com.getcode.model.chat.Chat import com.getcode.model.chat.ChatMessage import com.getcode.model.Cursor @@ -156,6 +157,15 @@ class ChatHistoryController @Inject constructor( chatEntries.value = updatedWithMessages.sortedByDescending { it.lastMessageMillis } } + fun addChat(chat: Chat) { + chatEntries.value = (chatEntries.value.orEmpty() + chat) + .sortedByDescending { it.lastMessageMillis } + } + + fun findChat(predicate: (Chat) -> Boolean): Chat? { + return chatEntries.value?.firstOrNull(predicate) + } + suspend fun advanceReadPointer(chatId: ID) { val owner = owner() ?: return @@ -181,17 +191,14 @@ class ChatHistoryController @Inject constructor( } } - fun advanceReadPointerUpTo(chatId: ID, timestamp: Long) { + fun resetUnreadCount(chatId: ID) { chatEntries.update { it?.toMutableList()?.apply chats@{ indexOfFirst { chat -> chat.id == chatId } .takeIf { index -> index >= 0 } ?.let { index -> val chat = this[index] - val newestMessage = chat.newestMessage - if (newestMessage != null) { - this[index] = chat.resetUnreadCount() - } + this[index] = chat.resetUnreadCount() } }?.toList() } diff --git a/api/src/main/java/com/getcode/network/ConversationController.kt b/api/src/main/java/com/getcode/network/ConversationController.kt index 0e0dfe017..edf5d487c 100644 --- a/api/src/main/java/com/getcode/network/ConversationController.kt +++ b/api/src/main/java/com/getcode/network/ConversationController.kt @@ -14,18 +14,23 @@ import com.getcode.model.ConversationMessageWithContent import com.getcode.model.ConversationWithLastPointers import com.getcode.model.ID import com.getcode.model.MessageStatus +import com.getcode.model.SocialUser +import com.getcode.model.chat.Chat import com.getcode.model.chat.ChatType import com.getcode.model.chat.MessageContent import com.getcode.model.chat.OutgoingMessageContent import com.getcode.model.chat.Platform import com.getcode.model.chat.isConversation import com.getcode.model.chat.selfId +import com.getcode.model.description import com.getcode.network.client.ChatMessageStreamReference import com.getcode.network.exchange.Exchange import com.getcode.network.repository.base58 import com.getcode.network.service.ChatServiceV2 +import com.getcode.solana.keys.PublicKey import com.getcode.utils.ErrorUtils import com.getcode.utils.bytes +import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -34,13 +39,14 @@ import javax.inject.Inject interface ConversationController { fun observeConversation(id: ID): Flow - suspend fun createConversation(identifier: ID, type: ChatType): Conversation + suspend fun createConversation(identifier: ID, with: SocialUser): Conversation suspend fun getConversation(identifier: ID): ConversationWithLastPointers? - suspend fun getOrCreateConversation(identifier: ID, type: ChatType): ConversationWithLastPointers + suspend fun getOrCreateConversation(identifier: ID, with: SocialUser): ConversationWithLastPointers fun openChatStream(scope: CoroutineScope, conversation: Conversation) fun closeChatStream() suspend fun hasInteracted(messageId: ID): Boolean suspend fun revealIdentity(conversationId: ID, platform: Platform, username: String): Result + suspend fun resetUnreadCount(conversationId: ID) suspend fun advanceReadPointer(conversationId: ID, messageId: ID, status: MessageStatus) suspend fun sendMessage(conversationId: ID, message: String): Result fun conversationPagingData(conversationId: ID): Flow> @@ -52,6 +58,7 @@ class ConversationStreamController @Inject constructor( private val chatService: ChatServiceV2, private val conversationMapper: ConversationMapper, private val messageWithContentMapper: ConversationMessageWithContentMapper, + private val tipController: TipController, ) : ConversationController { private val pagingConfig = PagingConfig(pageSize = 20) @@ -66,10 +73,11 @@ class ConversationStreamController @Inject constructor( return db.conversationDao().observeConversation(id) } - override suspend fun createConversation(identifier: ID, type: ChatType): Conversation { + override suspend fun createConversation(identifier: ID, with: SocialUser): Conversation { val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: throw IllegalStateException() - - return chatService.startChat(owner, identifier, type) + val self = tipController.connectedAccount.value ?: throw IllegalStateException() + return chatService.startChat(owner, self, with, identifier, ChatType.TwoWay) + .onSuccess { historyController.addChat(it) } .map { conversationMapper.map(it) } .onSuccess { // TODO: remove @@ -82,8 +90,6 @@ class ConversationStreamController @Inject constructor( ) ) db.conversationDao().upsertConversations(it) - // update chats - historyController.fetchChats() } .getOrThrow() } @@ -93,7 +99,7 @@ class ConversationStreamController @Inject constructor( return conversation } - override suspend fun getOrCreateConversation(identifier: ID, type: ChatType): ConversationWithLastPointers { + override suspend fun getOrCreateConversation(identifier: ID, with: SocialUser): ConversationWithLastPointers { var conversationByChatId = getConversation(identifier) if (conversationByChatId != null) { return conversationByChatId @@ -109,17 +115,16 @@ class ConversationStreamController @Inject constructor( return conversationByChatId } - return ConversationWithLastPointers(createConversation(identifier, type), emptyList()) + return ConversationWithLastPointers(createConversation(identifier, with), emptyList()) } @Throws(IllegalStateException::class) override fun openChatStream(scope: CoroutineScope, conversation: Conversation) { + runCatching { closeChatStream() } val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: throw IllegalStateException() - val chat = historyController.chats.value?.firstOrNull { - it.id == conversation.id - } ?: throw IllegalArgumentException("Unable to resolve chat for this conversation") - + val chat = historyController.findChat { it.id == conversation.id } + ?: throw IllegalArgumentException("Unable to resolve chat for this conversation") val memberId = chat.selfId ?: throw IllegalStateException("Not a member of this chat") stream = chatService.openChatStream( @@ -195,9 +200,8 @@ class ConversationStreamController @Inject constructor( override suspend fun revealIdentity(conversationId: ID, platform: Platform, username: String): Result { val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return Result.failure(Throwable("owner not found")) - val chat = historyController.chats.value?.firstOrNull { - it.id == conversationId - } ?: return Result.failure(Throwable("Chat not found")) + val chat = historyController.findChat { it.id == conversationId } + ?: return Result.failure(Throwable("Chat not found")) val memberId = chat.selfId ?: return Result.failure(Throwable("Not member of chat")) @@ -208,27 +212,29 @@ class ConversationStreamController @Inject constructor( } } + override suspend fun resetUnreadCount(conversationId: ID) { + val chat = historyController.findChat { it.id == conversationId } ?: return + historyController.resetUnreadCount(chat.id) + } + override suspend fun advanceReadPointer(conversationId: ID, messageId: ID, status: MessageStatus) { val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return - val chat = historyController.chats.value?.firstOrNull { - it.id == conversationId - } ?: return + val chat = historyController.findChat { it.id == conversationId } ?: return val memberId = chat.selfId ?: return chatService.advancePointer(owner, conversationId, memberId, messageId, status) .onSuccess { - + trace("advanced pointer for chat on ${messageId.description} => $status") }.onFailure { it.printStackTrace() } } override suspend fun sendMessage(conversationId: ID, message: String): Result { val owner = SessionManager.getOrganizer()?.ownerKeyPair ?: return Result.failure(Throwable("Owner not found")) - val chat = historyController.chats.value?.firstOrNull { - it.id == conversationId - } ?: return Result.failure(Throwable("Unable to find chat")) + val chat = historyController.findChat { it.id == conversationId } + ?: return Result.failure(Throwable("Chat not found")) val memberId = chat.selfId ?: return Result.failure(Throwable("Not a member of this chat")) diff --git a/api/src/main/java/com/getcode/network/ConversationListController.kt b/api/src/main/java/com/getcode/network/ConversationListController.kt index 453136051..4997ff8d1 100644 --- a/api/src/main/java/com/getcode/network/ConversationListController.kt +++ b/api/src/main/java/com/getcode/network/ConversationListController.kt @@ -1,7 +1,5 @@ package com.getcode.network -import androidx.paging.Pager -import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.PagingState import com.getcode.model.chat.Chat @@ -10,13 +8,12 @@ import javax.inject.Inject class ConversationListController @Inject constructor( private val historyController: ChatHistoryController, ) { - private val pagingConfig = PagingConfig(pageSize = 20) + val isLoadingChats: Boolean + get() = historyController.loadingMessages - fun observeConversations() = - Pager( - config = pagingConfig, - initialKey = null, - ) { ChatPagingSource(historyController.chats.value.orEmpty()) }.flow + fun observeConversations() = historyController.chats + + suspend fun fetchChats() = historyController.fetchChats(true) } class ChatPagingSource( diff --git a/api/src/main/java/com/getcode/network/TipController.kt b/api/src/main/java/com/getcode/network/TipController.kt index 177abb850..eb4d2c7ae 100644 --- a/api/src/main/java/com/getcode/network/TipController.kt +++ b/api/src/main/java/com/getcode/network/TipController.kt @@ -4,7 +4,7 @@ import com.getcode.manager.SessionManager import com.getcode.model.CodePayload import com.getcode.model.PrefsBool import com.getcode.model.PrefsString -import com.getcode.model.TipMetadata +import com.getcode.model.SocialUser import com.getcode.model.TwitterUser import com.getcode.network.client.Client import com.getcode.network.client.fetchTwitterUser @@ -62,7 +62,7 @@ class TipController @Inject constructor( var userMetadata: TwitterUser? = null private set - val connectedAccount: StateFlow = prefRepository.observeOrDefault(PrefsString.KEY_TIP_ACCOUNT, "") + val connectedAccount: StateFlow = prefRepository.observeOrDefault(PrefsString.KEY_TIP_ACCOUNT, "") .map { runCatching { Json.decodeFromString(it) }.getOrNull() } .distinctUntilChanged() .stateIn( diff --git a/api/src/main/java/com/getcode/network/api/ChatApiV2.kt b/api/src/main/java/com/getcode/network/api/ChatApiV2.kt index efbcc76db..6ffa2df90 100644 --- a/api/src/main/java/com/getcode/network/api/ChatApiV2.kt +++ b/api/src/main/java/com/getcode/network/api/ChatApiV2.kt @@ -12,6 +12,8 @@ import com.codeinc.gen.common.v1.Model import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.model.Cursor import com.getcode.model.ID +import com.getcode.model.SocialUser +import com.getcode.model.chat.ChatMember import com.getcode.model.chat.OutgoingMessageContent import com.getcode.model.chat.Platform import com.getcode.model.chat.StartChatRequest @@ -19,6 +21,7 @@ import com.getcode.model.chat.StartChatResponse import com.getcode.network.core.GrpcApi import com.getcode.network.repository.toByteString import com.getcode.network.repository.toSolanaAccount +import com.getcode.solana.keys.PublicKey import com.getcode.utils.bytes import com.getcode.utils.sign import io.grpc.ManagedChannel @@ -50,13 +53,20 @@ class ChatApiV2 @Inject constructor( ) : GrpcApi(managedChannel) { private val api = ChatGrpc.newStub(managedChannel) - fun startChat(owner: KeyPair, intentId: ID): Flow { + fun startChat( + owner: KeyPair, + self: SocialUser, + with: SocialUser, + intentId: ID + ): Flow { val request = StartChatRequest.newBuilder() .setOwner(owner.publicKeyBytes.toSolanaAccount()) + .setSelf(self.chatMemberIdentity) .setTwoWayChat( ChatService.StartTwoWayChatParameters.newBuilder() - .setIntentId(IntentId.newBuilder() - .setValue(intentId.toByteString())) + .setIdentity(with.chatMemberIdentity) + .setIntentId(IntentId.newBuilder().setValue(intentId.toByteString())) + .setOtherUser(with.tipAddress.bytes.toSolanaAccount()) .build() ) .apply { setSignature(sign(owner)) } @@ -254,4 +264,22 @@ class ChatApiV2 @Inject constructor( api.revealIdentity(request, observer) } -} \ No newline at end of file +} + +private val SocialUser.chatMemberIdentity: ChatMemberIdentity + get() { + val builder = ChatMemberIdentity.newBuilder() + .setUsername(username) + .setPlatform( + when (Platform.named(platform)) { + Platform.Unknown -> ChatService.Platform.UNKNOWN_PLATFORM + Platform.Twitter -> ChatService.Platform.TWITTER + } + ) + + if (imageUrl != null) { + builder.setProfilePicUrl(imageUrl) + } + + return builder.build() + } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/client/Client_Chat.kt b/api/src/main/java/com/getcode/network/client/Client_Chat.kt index 783a63ead..69ffbefdb 100644 --- a/api/src/main/java/com/getcode/network/client/Client_Chat.kt +++ b/api/src/main/java/com/getcode/network/client/Client_Chat.kt @@ -122,30 +122,4 @@ suspend fun Client.advancePointer( } else { chatServiceV1.advancePointer(owner, chat.id, to, status) } -} - -suspend fun Client.startChat( - owner: KeyPair, - reference: ID, - type: ChatType, -): Result { - return chatServiceV2.startChat(owner, reference, type) -} - -fun Client.openChatStream( - scope: CoroutineScope, - conversation: Conversation, - memberId: UUID, - owner: KeyPair, - chatLookup: (Conversation) -> Chat, - onEvent: (Result) -> Unit -): ChatMessageStreamReference { - return chatServiceV2.openChatStream( - scope = scope, - conversation = conversation, - memberId = memberId, - owner = owner, - chatLookup = chatLookup, - onEvent = onEvent - ) } \ No newline at end of file diff --git a/api/src/main/java/com/getcode/network/client/Client_Transaction.kt b/api/src/main/java/com/getcode/network/client/Client_Transaction.kt index 637c0af1c..1e12347ad 100644 --- a/api/src/main/java/com/getcode/network/client/Client_Transaction.kt +++ b/api/src/main/java/com/getcode/network/client/Client_Transaction.kt @@ -1,17 +1,17 @@ package com.getcode.network.client import android.annotation.SuppressLint -import android.content.Context import com.getcode.api.BuildConfig import com.getcode.db.Database import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.manager.SessionManager import com.getcode.manager.TopBarManager import com.getcode.model.AccountInfo -import com.getcode.model.TipMetadata +import com.getcode.model.SocialUser import com.getcode.model.Domain import com.getcode.model.Fee import com.getcode.model.GiftCard +import com.getcode.model.ID import com.getcode.model.IntentMetadata import com.getcode.model.Kin import com.getcode.model.KinAmount @@ -22,6 +22,7 @@ import com.getcode.model.intents.IntentEstablishRelationship import com.getcode.model.intents.IntentPrivateTransfer import com.getcode.model.intents.IntentPublicTransfer import com.getcode.model.intents.IntentRemoteSend +import com.getcode.model.intents.PrivateTransferMetadata import com.getcode.model.intents.SwapIntent import com.getcode.network.repository.TransactionRepository import com.getcode.network.repository.WithdrawException @@ -66,7 +67,7 @@ fun Client.transfer( rendezvousKey: PublicKey, destination: PublicKey, isWithdrawal: Boolean, - tippedUsername: String? = null, + metadata: PrivateTransferMetadata? = null, ): Completable { return transferWithResultSingle( amount, @@ -94,19 +95,20 @@ fun Client.transferWithResultSingle( rendezvousKey: PublicKey, destination: PublicKey, isWithdrawal: Boolean, - tipMetadata: TipMetadata? = null, -): Single> { + metadata: PrivateTransferMetadata? = null, +): Single> { return getTransferPreflightAction(amount.kin) .andThen(Single.defer { transactionRepository.transfer( - amount, fee, additionalFees, organizer, rendezvousKey, destination, isWithdrawal, tipMetadata + amount, fee, additionalFees, organizer, rendezvousKey, destination, isWithdrawal, metadata ) }) .map { if (it is IntentPrivateTransfer) { balanceController.setTray(organizer, it.resultTray) } - }.map { Result.success(Unit) } + it + }.map { Result.success(it.id.bytes) } .onErrorReturn { Result.failure(it) } } @@ -118,8 +120,8 @@ fun Client.transferWithResult( rendezvousKey: PublicKey, destination: PublicKey, isWithdrawal: Boolean, - tipMetadata: TipMetadata? = null, -): Result { + metadata: PrivateTransferMetadata? = null, +): Result { return transferWithResultSingle( amount = amount, fee = fee, @@ -128,7 +130,7 @@ fun Client.transferWithResult( rendezvousKey = rendezvousKey, destination = destination, isWithdrawal = isWithdrawal, - tipMetadata = tipMetadata + metadata = metadata, ).blockingGet() } diff --git a/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt b/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt index d0466c2ad..e749a68ff 100644 --- a/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/IdentityRepository.kt @@ -305,8 +305,8 @@ class IdentityRepository @Inject constructor( suspend fun fetchTwitterUserByUsername(owner: KeyPair, username: String): Result { val request = GetTwitterUserRequest.newBuilder() - .setUsername(username) .setRequestor(owner.publicKeyBytes.toSolanaAccount()) + .setUsername(username) .build() return try { @@ -352,8 +352,8 @@ class IdentityRepository @Inject constructor( suspend fun fetchTwitterUserByAddress(owner: KeyPair, address: PublicKey): Result { val request = GetTwitterUserRequest.newBuilder() - .setTipAddress(address.byteArray.toSolanaAccount()) .setRequestor(owner.publicKeyBytes.toSolanaAccount()) + .setTipAddress(address.byteArray.toSolanaAccount()) .build() return try { diff --git a/api/src/main/java/com/getcode/network/repository/PaymentRepository.kt b/api/src/main/java/com/getcode/network/repository/PaymentRepository.kt index 7ad1f295c..3ed89a66e 100644 --- a/api/src/main/java/com/getcode/network/repository/PaymentRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/PaymentRepository.kt @@ -5,10 +5,12 @@ import com.getcode.analytics.AnalyticsService import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.manager.SessionManager import com.getcode.model.CodePayload +import com.getcode.model.ID import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.LoginRequest -import com.getcode.model.TwitterUser +import com.getcode.model.SocialUser +import com.getcode.model.intents.PrivateTransferMetadata import com.getcode.network.BalanceController import com.getcode.network.client.Client import com.getcode.network.client.establishRelationshipSingle @@ -187,7 +189,7 @@ class PaymentRepository @Inject constructor( messagingRepository.rejectPayment(payload.rendezvous) } - suspend fun completeTipPayment(metadata: TwitterUser, amount: KinAmount) { + suspend fun completeTipPayment(socialUser: SocialUser, amount: KinAmount) { return suspendCancellableCoroutine { cont -> val organizer = SessionManager.getOrganizer() ?: throw PaymentError.OrganizerNotFound() @@ -204,9 +206,9 @@ class PaymentRepository @Inject constructor( fee = Kin.fromKin(0), additionalFees = emptyList(), rendezvousKey = rendezvous, - destination = metadata.tipAddress, + destination = socialUser.tipAddress, isWithdrawal = true, - tipMetadata = metadata, + metadata = PrivateTransferMetadata.Tip(socialUser), ) if (transferResult.isSuccess) { @@ -228,6 +230,48 @@ class PaymentRepository @Inject constructor( } } } + + suspend fun payForFriendship(user: SocialUser, amount: KinAmount): ID { + return suspendCancellableCoroutine { cont -> + val organizer = SessionManager.getOrganizer() ?: throw PaymentError.OrganizerNotFound() + + // Generally, we would use the rendezvous key that + // was generated from the scan code payload, however, + // tip codes are inherently deterministic and won't + // change so we need a unique rendezvous for every tx. + val rendezvous = PublicKey.generate() + + runCatching { + val transferResult = client.transferWithResult( + amount = amount, + organizer = organizer, + fee = Kin.fromKin(0), + additionalFees = emptyList(), + rendezvousKey = rendezvous, + destination = user.tipAddress, + isWithdrawal = true, + metadata = PrivateTransferMetadata.Chat(user), + ) + + if (transferResult.isSuccess) { + Completable.concatArray( + balanceController.fetchBalance(), + client.fetchLimits(isForce = true) + ).observeOn(Schedulers.io()).doOnComplete { +// analytics.transferForTip(amount = amount, successful = true) + cont.resume(transferResult.getOrNull().orEmpty()) + }.subscribe() + } else { + // pass exception down to onFailure for isolated handling + throw transferResult.exceptionOrNull() + ?: Throwable("Unable to complete payment") + } + }.onFailure { error -> + ErrorUtils.handleError(error) + cont.resumeWithException(error) + } + } + } } sealed interface PaymentError { diff --git a/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt b/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt index 05a79bc7c..1bbecc861 100644 --- a/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt +++ b/api/src/main/java/com/getcode/network/repository/TransactionRepository.kt @@ -13,7 +13,6 @@ import com.codeinc.gen.transaction.v2.TransactionService.SubmitIntentResponse.Re import com.getcode.api.BuildConfig import com.getcode.crypt.MnemonicPhrase import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.manager.SessionManager import com.getcode.model.* import com.getcode.model.intents.ActionGroup import com.getcode.model.intents.IntentCreateAccounts @@ -27,6 +26,7 @@ import com.getcode.model.intents.IntentRemoteReceive import com.getcode.model.intents.IntentRemoteSend import com.getcode.model.intents.IntentType import com.getcode.model.intents.IntentUpgradePrivacy +import com.getcode.model.intents.PrivateTransferMetadata import com.getcode.model.intents.ServerParameter import com.getcode.network.api.TransactionApiV2 import com.getcode.network.integrity.DeviceCheck @@ -139,7 +139,7 @@ class TransactionRepository @Inject constructor( rendezvousKey: PublicKey, destination: PublicKey, isWithdrawal: Boolean, - tipMetadata: TipMetadata? = null, + metadata: PrivateTransferMetadata? = null, ): Single { if (isMock()) return Single.just( IntentPrivateTransfer( @@ -153,13 +153,12 @@ class TransactionRepository @Inject constructor( additionalFees = emptyList(), resultTray = organizer.tray, isWithdrawal = isWithdrawal, - tipMetadata = tipMetadata + metadata = metadata ) as IntentType ) .delay(1, TimeUnit.SECONDS) val intent = IntentPrivateTransfer.newInstance( - context = context, rendezvousKey = rendezvousKey, organizer = organizer, destination = destination, @@ -167,7 +166,7 @@ class TransactionRepository @Inject constructor( fee = fee, additionalFees = additionalFees, isWithdrawal = isWithdrawal, - tipMetadata = tipMetadata + metadata = metadata ) return submit(intent = intent, owner = organizer.tray.owner.getCluster().authority.keyPair) diff --git a/api/src/main/java/com/getcode/network/service/ChatServiceV2.kt b/api/src/main/java/com/getcode/network/service/ChatServiceV2.kt index 741d71b25..77474d732 100644 --- a/api/src/main/java/com/getcode/network/service/ChatServiceV2.kt +++ b/api/src/main/java/com/getcode/network/service/ChatServiceV2.kt @@ -18,7 +18,9 @@ import com.getcode.model.Cursor import com.getcode.model.chat.ChatMessage import com.getcode.model.ID import com.getcode.model.MessageStatus +import com.getcode.model.SocialUser import com.getcode.model.chat.Chat +import com.getcode.model.chat.ChatIdV2 import com.getcode.model.chat.ChatStreamEventUpdate import com.getcode.model.chat.ChatType import com.getcode.model.chat.OutgoingMessageContent @@ -284,6 +286,8 @@ class ChatServiceV2 @Inject constructor( suspend fun startChat( owner: KeyPair, + self: SocialUser, + with: SocialUser, intentId: ID, type: ChatType ): Result { @@ -293,7 +297,7 @@ class ChatServiceV2 @Inject constructor( ChatType.Notification -> throw IllegalArgumentException("Unable to create notification chats from client") ChatType.TwoWay -> { try { - networkOracle.managedRequest(api.startChat(owner, intentId)) + networkOracle.managedRequest(api.startChat(owner, self, with, intentId)) .map { response -> when (response.result) { ChatService.StartChatResponse.Result.OK -> { @@ -450,7 +454,7 @@ class ChatServiceV2 @Inject constructor( val request = StreamChatEventsRequest.newBuilder() .setOpenStream(OpenChatEventStream.newBuilder() .setChatId( - ChatService.ChatId.newBuilder() + ChatIdV2.newBuilder() .setValue(conversation.id.toByteString()) .build() ) diff --git a/api/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt b/api/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt index 31b7731fe..b1006a985 100644 --- a/api/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt +++ b/api/src/main/java/com/getcode/solana/builder/TransactionBuilder.kt @@ -1,8 +1,8 @@ package com.getcode.solana.builder -import com.getcode.model.TipMetadata import com.getcode.solana.keys.Hash import com.getcode.model.Kin +import com.getcode.model.intents.PrivateTransferMetadata import com.getcode.model.intents.SwapConfigParameters import com.getcode.solana.Instruction import com.getcode.solana.SolanaTransaction @@ -105,7 +105,7 @@ object TransactionBuilder { recentBlockhash: Hash, kreIndex: Int, legacy: Boolean = false, - tipMetadata: TipMetadata? = null, + metadata: PrivateTransferMetadata? = null, ): SolanaTransaction { val instructions = mutableListOf() @@ -116,8 +116,13 @@ object TransactionBuilder { kreIndex = kreIndex ).instruction(), ) - if (tipMetadata != null) { - instructions.add(MemoProgram_Memo.newInstance(tipMetadata).instruction()) + + when (metadata) { + is PrivateTransferMetadata.Chat -> Unit + is PrivateTransferMetadata.Tip -> { + instructions.add(MemoProgram_Memo.newInstance(metadata.socialUser).instruction()) + } + null -> Unit } instructions.addAll( diff --git a/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt b/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt index 743ff2a42..96509ed72 100644 --- a/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt +++ b/api/src/main/java/com/getcode/solana/instructions/programs/MemoProgram_Memo.kt @@ -1,6 +1,6 @@ package com.getcode.solana.instructions.programs -import com.getcode.model.TipMetadata +import com.getcode.model.SocialUser import com.getcode.solana.AgoraMemo import com.getcode.solana.Instruction import com.getcode.solana.TransferType @@ -23,7 +23,7 @@ class MemoProgram_Memo(data: List) : MemoProgram(data) { ) } - fun newInstance(tipMetadata: TipMetadata): MemoProgram_Memo { + fun newInstance(tipMetadata: SocialUser): MemoProgram_Memo { val memo = "tip:${tipMetadata.platform}:${tipMetadata.username}" return MemoProgram_Memo(memo.toByteArray().toList()) diff --git a/app/src/main/java/com/getcode/CodeApp.kt b/app/src/main/java/com/getcode/CodeApp.kt index 75e4eec82..d6b1cc339 100644 --- a/app/src/main/java/com/getcode/CodeApp.kt +++ b/app/src/main/java/com/getcode/CodeApp.kt @@ -44,6 +44,7 @@ import com.getcode.ui.components.ModalContainer import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.components.TitleBar import com.getcode.ui.components.bars.TopBarContainer +import com.getcode.ui.modals.ConfirmationModals import com.getcode.ui.utils.getActivity import com.getcode.ui.utils.getActivityScopedViewModel import com.getcode.ui.utils.measured @@ -157,6 +158,7 @@ fun CodeApp(tipsEngine: TipsEngine) { BiometricsBlockingView(modifier = Modifier.fillMaxSize(), biometricsState) TopBarContainer(appState) BottomBarContainer(appState) + ConfirmationModals(Modifier.fillMaxSize()) } } diff --git a/app/src/main/java/com/getcode/Locals.kt b/app/src/main/java/com/getcode/Locals.kt index 02925b22f..c4b9541ee 100644 --- a/app/src/main/java/com/getcode/Locals.kt +++ b/app/src/main/java/com/getcode/Locals.kt @@ -16,7 +16,7 @@ import com.getcode.util.PhoneUtils import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.network.NetworkObserverStub -val LocalSession: ProvidableCompositionLocal = staticCompositionLocalOf { null } +val LocalSession: ProvidableCompositionLocal = staticCompositionLocalOf { null } val LocalAnalytics: ProvidableCompositionLocal = staticCompositionLocalOf { AnalyticsServiceNull() } val LocalNetworkObserver: ProvidableCompositionLocal = staticCompositionLocalOf { NetworkObserverStub() } val LocalPhoneFormatter: ProvidableCompositionLocal = staticCompositionLocalOf { null } diff --git a/app/src/main/java/com/getcode/Session.kt b/app/src/main/java/com/getcode/SessionController.kt similarity index 83% rename from app/src/main/java/com/getcode/Session.kt rename to app/src/main/java/com/getcode/SessionController.kt index 358db60be..08fb3f8a4 100644 --- a/app/src/main/java/com/getcode/Session.kt +++ b/app/src/main/java/com/getcode/SessionController.kt @@ -10,8 +10,6 @@ import android.os.Build import android.view.WindowManager import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri -import androidx.lifecycle.viewModelScope -import cafe.adriel.voyager.core.model.ScreenModel import com.getcode.analytics.AnalyticsManager import com.getcode.analytics.AnalyticsService import com.getcode.domain.CashLinkManager @@ -31,14 +29,15 @@ import com.getcode.model.Feature import com.getcode.model.Fiat import com.getcode.model.FlippableTipCardFeature import com.getcode.model.GalleryFeature +import com.getcode.model.ID import com.getcode.model.IntentMetadata import com.getcode.model.InvertedDragZoomFeature import com.getcode.model.Kin import com.getcode.model.KinAmount import com.getcode.model.Kind import com.getcode.model.PrefsBool -import com.getcode.model.Rate import com.getcode.model.RequestKinFeature +import com.getcode.model.SocialUser import com.getcode.model.TwitterUser import com.getcode.model.Username import com.getcode.model.notifications.NotificationType @@ -50,7 +49,7 @@ import com.getcode.models.DeepLinkRequest import com.getcode.models.LoginConfirmation import com.getcode.models.PaymentConfirmation import com.getcode.models.PaymentValuation -import com.getcode.models.TipConfirmation +import com.getcode.models.SocialUserPaymentConfirmation import com.getcode.models.amountFloored import com.getcode.network.BalanceController import com.getcode.network.ChatHistoryController @@ -97,11 +96,9 @@ import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.nonce import com.getcode.utils.trace import com.getcode.vendor.Base58 -import com.getcode.view.BaseViewModel import com.getcode.view.main.scanner.UiElement import com.kik.kikx.kikcodes.implementation.KikCodeAnalyzer import com.kik.kikx.models.ScannableKikCode -import dagger.hilt.android.lifecycle.HiltViewModel import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope @@ -132,6 +129,7 @@ import java.util.Timer import java.util.TimerTask import java.util.concurrent.TimeUnit import javax.inject.Inject +import javax.inject.Singleton import kotlin.concurrent.schedule import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -177,6 +175,7 @@ sealed interface SessionEvent { data object PresentTipEntry : SessionEvent data object RequestNotificationPermissions : SessionEvent data class SendIntent(val intent: Intent) : SessionEvent + data class OnChatPaidForSuccessfully(val intentId: ID, val user: SocialUser): SessionEvent } enum class RestrictionType { @@ -186,8 +185,8 @@ enum class RestrictionType { } @SuppressLint("CheckResult") -@HiltViewModel -class Session @Inject constructor( +@Singleton +class SessionController @Inject constructor( private val client: Client, private val receiveTransactionRepository: ReceiveTransactionRepository, private val paymentRepository: PaymentRepository, @@ -211,8 +210,10 @@ class Session @Inject constructor( appSettings: AppSettingsRepository, betaFlagsRepository: BetaFlagsRepository, features: FeatureRepository, -) : BaseViewModel(resources), ScreenModel { - val uiFlow = MutableStateFlow(SessionState()) +) { + private val scope = CoroutineScope(Dispatchers.IO) + + val state = MutableStateFlow(SessionState()) private val _eventFlow: MutableSharedFlow = MutableSharedFlow() val eventFlow: SharedFlow = _eventFlow.asSharedFlow() @@ -226,74 +227,74 @@ class Session @Inject constructor( .map { it.cameraStartByDefault } .distinctUntilChanged() .onEach { cameraAutoStart -> - uiFlow.update { + state.update { it.copy(autoStartCamera = cameraAutoStart) } - }.launchIn(viewModelScope) + }.launchIn(scope) features.buyModule .distinctUntilChanged() .onEach { module -> - uiFlow.update { + state.update { it.copy(buyModule = module) } - }.launchIn(viewModelScope) + }.launchIn(scope) features.requestKin .distinctUntilChanged() .onEach { module -> - uiFlow.update { + state.update { it.copy(requestKin = module) } - }.launchIn(viewModelScope) + }.launchIn(scope) features.cameraGestures .distinctUntilChanged() .onEach { module -> - uiFlow.update { + state.update { it.copy(cameraGestures = module) } - }.launchIn(viewModelScope) + }.launchIn(scope) features.invertedDragZoom .distinctUntilChanged() .onEach { module -> - uiFlow.update { + state.update { it.copy(invertedDragZoom = module) } - }.launchIn(viewModelScope) + }.launchIn(scope) features.galleryEnabled .distinctUntilChanged() .onEach { module -> - uiFlow.update { + state.update { it.copy(gallery = module) } - }.launchIn(viewModelScope) + }.launchIn(scope) features.tipCardFlippable .distinctUntilChanged() .onEach { module -> - uiFlow.update { + state.update { it.copy(flippableTipCard = module) } - }.launchIn(viewModelScope) + }.launchIn(scope) betaFlagsRepository.observe() .distinctUntilChanged() .onEach { betaFlags -> - uiFlow.update { it.copy(scannerElements = buildScannerElements(betaFlags)) } - }.launchIn(viewModelScope) + state.update { it.copy(scannerElements = buildScannerElements(betaFlags)) } + }.launchIn(scope) tipController.showTwitterSplat .onEach { splat -> - viewModelScope.launch { + scope.launch { if (splat) { delay(300) } else { delay(500) } - uiFlow.update { + state.update { it.copy(splatTipCard = splat) } } @@ -304,7 +305,7 @@ class Session @Inject constructor( .filter { tipController.verificationInProgress.value } .filterNotNull() .distinctUntilChanged() - .filter { uiFlow.value.isCameraScanEnabled } + .filter { state.value.isCameraScanEnabled } .onEach { when (it) { is TwitterUser -> { @@ -324,23 +325,23 @@ class Session @Inject constructor( ) } } - }.launchIn(viewModelScope) + }.launchIn(scope) tipController.connectedAccount .onEach { account -> - uiFlow.update { + state.update { it.copy( tipCardConnected = account != null ) } - }.launchIn(viewModelScope) + }.launchIn(scope) StatusRepository().getIsUpgradeRequired(BuildConfig.VERSION_CODE) .subscribeOn(Schedulers.computation()) .timeout(15_000L, TimeUnit.MILLISECONDS) .onErrorComplete { false } .subscribe { isUpgradeRequired -> - uiFlow.update { m -> m.copy(restrictionType = if (isUpgradeRequired) RestrictionType.FORCE_UPGRADE else null) } + state.update { m -> m.copy(restrictionType = if (isUpgradeRequired) RestrictionType.FORCE_UPGRADE else null) } } SessionManager.authState @@ -376,7 +377,7 @@ class Session @Inject constructor( prefRepository.set(PrefsBool.IS_ELIGIBLE_GET_FIRST_KIN_AIRDROP, false) } ) - .launchIn(viewModelScope) + .launchIn(scope) combine( exchange.observeLocalRate(), @@ -388,47 +389,47 @@ class Session @Inject constructor( KinAmount.newInstance(Kin.fromKin(balance), rate) } }.filterNotNull().onEach { balanceInKin -> - uiFlow.update { + state.update { it.copy(balance = balanceInKin) } - }.launchIn(viewModelScope) + }.launchIn(scope) historyController.notificationsUnreadCount .distinctUntilChanged() .map { it } .onEach { count -> - uiFlow.update { it.copy(notificationUnreadCount = count) } - }.launchIn(viewModelScope) + state.update { it.copy(notificationUnreadCount = count) } + }.launchIn(scope) prefRepository.observeOrDefault(PrefsBool.LOG_SCAN_TIMES, false) .flowOn(Dispatchers.IO) .onEach { log -> withContext(Dispatchers.Main) { - uiFlow.update { + state.update { it.copy(logScanTimes = log) } } - }.launchIn(viewModelScope) + }.launchIn(scope) prefRepository.observeOrDefault(PrefsBool.VIBRATE_ON_SCAN, false) .flowOn(Dispatchers.IO) .onEach { enabled -> withContext(Dispatchers.Main) { - uiFlow.update { + state.update { it.copy(vibrateOnScan = enabled) } } - }.launchIn(viewModelScope) + }.launchIn(scope) prefRepository.observeOrDefault(PrefsBool.SHOW_CONNECTIVITY_STATUS, false) .flowOn(Dispatchers.IO) .onEach { enabled -> withContext(Dispatchers.Main) { - uiFlow.update { + state.update { it.copy(showNetworkOffline = enabled) } } - }.launchIn(viewModelScope) + }.launchIn(scope) CoroutineScope(Dispatchers.IO).launch { SessionManager.authState @@ -436,7 +437,7 @@ class Session @Inject constructor( .collectLatest { it.let { state -> if (state.isTimelockUnlocked) { - uiFlow.update { m -> m.copy(restrictionType = RestrictionType.TIMELOCK_UNLOCKED) } + this@SessionController.state.update { m -> m.copy(restrictionType = RestrictionType.TIMELOCK_UNLOCKED) } } } } @@ -463,11 +464,11 @@ class Session @Inject constructor( } fun onCameraScanning(scanning: Boolean) { - uiFlow.update { it.copy(isCameraScanEnabled = scanning) } + state.update { it.copy(isCameraScanEnabled = scanning) } } fun onCameraPermissionResult(result: PermissionResult) { - uiFlow.update { it.copy(isCameraPermissionGranted = result == PermissionResult.Granted) } + state.update { it.copy(isCameraPermissionGranted = result == PermissionResult.Granted) } } fun showBill( @@ -488,7 +489,7 @@ class Session @Inject constructor( when (bill) { is Bill.Cash -> { if (bill.kind == Bill.Kind.firstKin) { - uiFlow.update { + state.update { it.copy( billState = it.billState.copy( primaryAction = null, @@ -497,7 +498,7 @@ class Session @Inject constructor( ) } } else { - uiFlow.update { + state.update { it.copy( billState = it.billState.copy( primaryAction = BillState.Action.Send { onRemoteSend() }, @@ -519,7 +520,7 @@ class Session @Inject constructor( cancelSend(PresentationStyle.Pop) vibrator.vibrate() - viewModelScope.launch { + scope.launch { client.fetchLimits(true).subscribe({}, ErrorUtils::handleError) } }, @@ -551,7 +552,7 @@ class Session @Inject constructor( private fun presentSend(data: List, bill: Bill, isVibrate: Boolean = false) { println("present send") if (bill.didReceive) { - uiFlow.update { + state.update { val billState = it.billState it.copy( billState = billState.copy( @@ -566,7 +567,7 @@ class Session @Inject constructor( val style: PresentationStyle = if (bill.didReceive) PresentationStyle.Pop else PresentationStyle.Slide - uiFlow.update { + state.update { val billState = it.billState it.copy( presentationStyle = style, @@ -602,16 +603,16 @@ class Session @Inject constructor( cashLinkManager.cancelSend() BottomBarManager.clearByType(BottomBarManager.BottomBarMessageType.REMOTE_SEND) - viewModelScope.launch { + scope.launch { val shown = showToastIfNeeded(style) withContext(Dispatchers.Main) { - uiFlow.update { + state.update { it.copy( presentationStyle = style, ) } - uiFlow.update { + state.update { it.copy( billState = it.billState.copy( bill = null, @@ -630,7 +631,7 @@ class Session @Inject constructor( delay(5.seconds.inWholeMilliseconds) } withContext(Dispatchers.Main) { - uiFlow.update { + state.update { it.copy( billState = it.billState.copy(showToast = false) ) @@ -642,7 +643,7 @@ class Session @Inject constructor( private fun showToastIfNeeded( style: PresentationStyle, ): Boolean { - val billState = uiFlow.value.billState + val billState = state.value.billState val bill = billState.bill ?: return false if (style is PresentationStyle.Pop || billState.showToast) { @@ -666,10 +667,10 @@ class Session @Inject constructor( isDeposit: Boolean = false, initialDelay: Duration = 500.milliseconds ) { - viewModelScope.launch { + scope.launch { delay(initialDelay) if (amount.kin.toKinTruncatingLong() == 0L) { - uiFlow.update { uiModel -> + state.update { uiModel -> val billState = uiModel.billState uiModel.copy( billState = billState.copy( @@ -680,7 +681,7 @@ class Session @Inject constructor( return@launch } - uiFlow.update { + state.update { it.copy( billState = it.billState.copy( showToast = true, @@ -691,7 +692,7 @@ class Session @Inject constructor( delay(5.seconds) - uiFlow.update { uiModel -> + state.update { uiModel -> val billState = uiModel.billState uiModel.copy( billState = billState.copy( @@ -702,7 +703,7 @@ class Session @Inject constructor( // wait for animation to run delay(500.milliseconds) - uiFlow.update { uiModel -> + state.update { uiModel -> val billState = uiModel.billState uiModel.copy( billState = billState.copy( @@ -719,7 +720,7 @@ class Session @Inject constructor( scanProcessingTime = System.currentTimeMillis() } - if (uiFlow.value.vibrateOnScan) { + if (state.value.vibrateOnScan) { vibrator.tick() } @@ -781,7 +782,7 @@ class Session @Inject constructor( } private fun attemptPayment(payload: CodePayload, request: DeepLinkRequest? = null) = - viewModelScope.launch { + scope.launch { val (amount, p) = paymentRepository.attemptRequest(payload) ?: return@launch BottomBarManager.clear() @@ -793,7 +794,7 @@ class Session @Inject constructor( } private fun attemptTip(codePayload: CodePayload, request: DeepLinkRequest? = null) = - viewModelScope.launch { + scope.launch { BottomBarManager.clear() val username = codePayload.username ?: request?.tipRequest?.username ?: return@launch presentTipCard(payload = codePayload, username = username) @@ -803,7 +804,7 @@ class Session @Inject constructor( client.receiveIfNeeded().subscribe({}, ErrorUtils::handleError) } - fun presentShareableTipCard() = viewModelScope.launch { + fun presentShareableTipCard() = scope.launch { val username = tipController.connectedAccount.value?.username ?: return@launch val code = CodePayload( kind = Kind.Tip, @@ -820,9 +821,9 @@ class Session @Inject constructor( ) withContext(Dispatchers.Main) { - uiFlow.update { + state.update { val billState = it.billState.copy( - bill = Bill.Tip(code, canFlip = uiFlow.value.flippableTipCard.enabled), + bill = Bill.Tip(code, canFlip = state.value.flippableTipCard.enabled), primaryAction = BillState.Action.Share { onRemoteSend() }, secondaryAction = BillState.Action.Cancel(::cancelSend) ) @@ -865,7 +866,7 @@ class Session @Inject constructor( onPositive = { when { isDenied -> { - viewModelScope.launch { + scope.launch { _eventFlow.emit(SessionEvent.RequestNotificationPermissions) } } @@ -890,7 +891,7 @@ class Session @Inject constructor( vibrator.vibrate() withContext(Dispatchers.Main) { - uiFlow.update { + state.update { val billState = it.billState.copy( bill = Bill.Tip(payload), primaryAction = null, @@ -921,7 +922,7 @@ class Session @Inject constructor( R.string.subtitle_linkingTwitterPrompt, username ) ) - viewModelScope.launch { + scope.launch { _eventFlow.emit(SessionEvent.SendIntent(intent)) } cancelTip() @@ -937,21 +938,14 @@ class Session @Inject constructor( } } - fun presentTipConfirmation(amount: KinAmount, user: TwitterUser? = null) { + fun presentTipConfirmation(amount: KinAmount) { val scannedUserData = tipController.scannedUserData?.second - val payload = if (user != null) { - CodePayload( - kind = Kind.Tip, - value = Username(user.username) - ) - } else { - scannedUserData - } ?: return + val payload = scannedUserData ?: return + val metadata = tipController.userMetadata ?: return - val metadata = user ?: tipController.userMetadata ?: return - uiFlow.update { + state.update { val billState = it.billState.copy( - tipConfirmation = TipConfirmation( + socialUserPaymentConfirmation = SocialUserPaymentConfirmation( state = ConfirmationState.AwaitingConfirmation, payload = payload, amount = amount, @@ -963,17 +957,62 @@ class Session @Inject constructor( } } - fun completeTipPayment() = viewModelScope.launch { - val tipConfirmation = uiFlow.value.billState.tipConfirmation ?: return@launch + fun presentPrivatePaymentConfirmation(socialUser: SocialUser, amount: KinAmount) { + val payload = CodePayload( + kind = Kind.Tip, + value = Username(socialUser.username), + ) + + + state.update { + val billState = it.billState.copy( + socialUserPaymentConfirmation = SocialUserPaymentConfirmation( + state = ConfirmationState.AwaitingConfirmation, + payload = payload, + amount = amount, + metadata = socialUser, + isPrivate = true, + showScrim = true + ) + ) + + it.copy(billState = billState) + } + } + + fun completeTipPayment() = scope.launch { + val tipConfirmation = state.value.billState.socialUserPaymentConfirmation ?: return@launch val metadata = tipController.userMetadata ?: return@launch val amount = tipConfirmation.amount - uiFlow.update { + fun showError() { + TopBarManager.showMessage( + resources.getString(R.string.error_title_payment_failed), + resources.getString(R.string.error_description_payment_failed), + ) + + state.update { uiModel -> + uiModel.copy( + presentationStyle = PresentationStyle.Hidden, + billState = uiModel.billState.copy( + bill = null, + showToast = false, + socialUserPaymentConfirmation = null, + toast = null, + valuation = null, + primaryAction = null, + secondaryAction = null, + ) + ) + } + } + + state.update { val billState = it.billState it.copy( billState = billState.copy( - tipConfirmation = tipConfirmation.copy(state = ConfirmationState.Sending) + socialUserPaymentConfirmation = tipConfirmation.copy(state = ConfirmationState.Sending) ), ) } @@ -982,13 +1021,13 @@ class Session @Inject constructor( paymentRepository.completeTipPayment(metadata, amount) }.onSuccess { historyController.fetchChats() - uiFlow.update { + state.update { val billState = it.billState - val confirmation = it.billState.tipConfirmation ?: return@update it + val confirmation = it.billState.socialUserPaymentConfirmation ?: return@update it it.copy( billState = billState.copy( - tipConfirmation = confirmation.copy(state = ConfirmationState.Sent), + socialUserPaymentConfirmation = confirmation.copy(state = ConfirmationState.Sent), ), ) } @@ -1001,13 +1040,105 @@ class Session @Inject constructor( resources.getString(R.string.error_description_payment_failed), ) - uiFlow.update { uiModel -> + state.update { uiModel -> + uiModel.copy( + presentationStyle = PresentationStyle.Hidden, + billState = uiModel.billState.copy( + bill = null, + showToast = false, + socialUserPaymentConfirmation = null, + toast = null, + valuation = null, + primaryAction = null, + secondaryAction = null, + ) + ) + } + } + + if (state.value.billState.socialUserPaymentConfirmation == null) { + showError() + return@launch + } + + state.update { + val billState = it.billState + it.copy( + billState = billState.copy( + socialUserPaymentConfirmation = tipConfirmation.copy(state = ConfirmationState.Sending) + ), + ) + } + + runCatching { + paymentRepository.completeTipPayment(tipConfirmation.metadata, tipConfirmation.amount) + }.onSuccess { + historyController.fetchChats() + state.update { + val billState = it.billState + val confirmation = it.billState.socialUserPaymentConfirmation ?: return@update it + + it.copy( + billState = billState.copy( + socialUserPaymentConfirmation = confirmation.copy(state = ConfirmationState.Sent), + ), + ) + } + delay(400.milliseconds) + cancelTip() + showToast(tipConfirmation.amount, isDeposit = false) + }.onFailure { + showError() + } + } + + fun completePrivatePayment() = scope.launch { + val confirmation = state.value.billState.socialUserPaymentConfirmation ?: return@launch + val user = confirmation.metadata + val amount = confirmation.amount + + state.update { + val billState = it.billState + it.copy( + billState = billState.copy( + socialUserPaymentConfirmation = billState.socialUserPaymentConfirmation?.copy(state = ConfirmationState.Sending) + ), + ) + } + + runCatching { + paymentRepository.payForFriendship(user, amount) + }.onSuccess { + historyController.fetchChats() + + state.update { s -> + val billState = s.billState + val socialUserPaymentConfirmation = s.billState.socialUserPaymentConfirmation ?: return@update s + + s.copy( + billState = billState.copy( + socialUserPaymentConfirmation = socialUserPaymentConfirmation.copy(state = ConfirmationState.Sent), + ), + ) + } + delay(1.seconds) + cancelTip() + delay(400.milliseconds) + showToast(amount, isDeposit = false) + _eventFlow.emit(SessionEvent.OnChatPaidForSuccessfully(it, user)) + }.onFailure { + TopBarManager.showMessage( + resources.getString(R.string.error_title_payment_failed), + resources.getString(R.string.error_description_payment_failed), + ) + + state.update { uiModel -> uiModel.copy( presentationStyle = PresentationStyle.Hidden, billState = uiModel.billState.copy( bill = null, showToast = false, - tipConfirmation = null, + socialUserPaymentConfirmation = null, toast = null, valuation = null, primaryAction = null, @@ -1022,17 +1153,17 @@ class Session @Inject constructor( // Cancelling from amount entry is triggered by a UI event. // To distinguish between a valid "Next" action that will // also dismiss the entry screen, we need to check explicitly - if (uiFlow.value.billState.tipConfirmation == null) { + if (state.value.billState.socialUserPaymentConfirmation == null) { cancelTip() } } fun cancelTip() { tipController.reset() - uiFlow.update { + state.update { val billState = it.billState.copy( bill = null, - tipConfirmation = null, + socialUserPaymentConfirmation = null, valuation = null, primaryAction = null, secondaryAction = null, @@ -1049,7 +1180,7 @@ class Session @Inject constructor( amount: KinAmount, payload: CodePayload?, request: DeepLinkRequest? = null - ) = viewModelScope.launch { + ) = scope.launch { val code: CodePayload if (payload != null) { code = payload @@ -1072,7 +1203,7 @@ class Session @Inject constructor( val isReceived = payload != null val presentationStyle = if (isReceived) PresentationStyle.Pop else PresentationStyle.Slide - uiFlow.update { + state.update { var billState = it.billState.copy( bill = Bill.Payment(amount, code, request), valuation = PaymentValuation(amount), @@ -1111,12 +1242,12 @@ class Session @Inject constructor( vibrator.vibrate() } - fun completePayment() = viewModelScope.launch { + fun completePayment() = scope.launch { // keep bill active while sending cashLinkManager.cancelBillTimeout() - val paymentConfirmation = uiFlow.value.billState.paymentConfirmation ?: return@launch - uiFlow.update { + val paymentConfirmation = state.value.billState.paymentConfirmation ?: return@launch + state.update { val billState = it.billState it.copy( billState = billState.copy( @@ -1132,7 +1263,7 @@ class Session @Inject constructor( }.onSuccess { historyController.fetchChats() - uiFlow.update { + state.update { val billState = it.billState val confirmation = it.billState.paymentConfirmation ?: return@update it @@ -1152,7 +1283,7 @@ class Session @Inject constructor( ) ErrorUtils.handleError(error) - uiFlow.update { uiModel -> + state.update { uiModel -> uiModel.copy( presentationStyle = PresentationStyle.Hidden, billState = uiModel.billState.copy( @@ -1170,8 +1301,8 @@ class Session @Inject constructor( } private fun cancelPayment(rejected: Boolean, ignoreRedirect: Boolean = false) { - val paymentRendezous = uiFlow.value.billState.paymentConfirmation - val bill = uiFlow.value.billState.bill ?: return + val paymentRendezous = state.value.billState.paymentConfirmation + val bill = state.value.billState.bill ?: return val amount = bill.amount val request = bill.metadata.request @@ -1181,7 +1312,7 @@ class Session @Inject constructor( analytics.requestHidden(amount = amount) - uiFlow.update { + state.update { it.copy( presentationStyle = PresentationStyle.Slide, billState = it.billState.copy( @@ -1194,7 +1325,7 @@ class Session @Inject constructor( ) } - viewModelScope.launch { + scope.launch { delay(300) if (rejected) { if (!ignoreRedirect) { @@ -1227,11 +1358,11 @@ class Session @Inject constructor( } fun rejectPayment(ignoreRedirect: Boolean = false) { - val payload = uiFlow.value.billState.paymentConfirmation?.payload + val payload = state.value.billState.paymentConfirmation?.payload cancelPayment(true, ignoreRedirect) payload ?: return - viewModelScope.launch { + scope.launch { paymentRepository.rejectPayment(payload) } } @@ -1254,7 +1385,7 @@ class Session @Inject constructor( ) { vibrator.vibrate() - uiFlow.update { + state.update { it.copy( presentationStyle = PresentationStyle.Pop, billState = it.billState.copy( @@ -1275,12 +1406,12 @@ class Session @Inject constructor( } } - fun completeLogin() = viewModelScope.launch { + fun completeLogin() = scope.launch { val organizer = SessionManager.getOrganizer() ?: return@launch - val loginConfirmation = uiFlow.value.billState.loginConfirmation ?: return@launch + val loginConfirmation = state.value.billState.loginConfirmation ?: return@launch val domain = loginConfirmation.domain - uiFlow.update { + state.update { val billState = it.billState it.copy( billState = billState.copy( @@ -1312,7 +1443,7 @@ class Session @Inject constructor( ) ErrorUtils.handleError(it) - uiFlow.update { uiModel -> + state.update { uiModel -> uiModel.copy( presentationStyle = PresentationStyle.Hidden, billState = uiModel.billState.copy( @@ -1327,7 +1458,7 @@ class Session @Inject constructor( ) } }.onSuccess { - uiFlow.update { + state.update { val billState = it.billState val confirmation = it.billState.loginConfirmation ?: return@update it @@ -1344,9 +1475,9 @@ class Session @Inject constructor( } private fun cancelLogin(rejected: Boolean) { - val bill = uiFlow.value.billState.bill ?: return + val bill = state.value.billState.bill ?: return val request = bill.metadata.request - uiFlow.update { + state.update { it.copy( presentationStyle = PresentationStyle.Slide, billState = it.billState.copy( @@ -1362,7 +1493,7 @@ class Session @Inject constructor( ) } - viewModelScope.launch { + scope.launch { delay(300) if (rejected) { request?.cancelUrl?.let { url -> @@ -1389,7 +1520,7 @@ class Session @Inject constructor( } fun rejectLogin() { - val rendezvous = uiFlow.value.billState.loginConfirmation?.payload?.rendezvous + val rendezvous = state.value.billState.loginConfirmation?.payload?.rendezvous if (rendezvous == null) { Timber.e("Failed to reject login, no rendezous found in login confirmation.") return @@ -1397,7 +1528,7 @@ class Session @Inject constructor( cancelLogin(rejected = true) - viewModelScope.launch { + scope.launch { client.rejectLogin(rendezvous) } } @@ -1443,7 +1574,7 @@ class Session @Inject constructor( ) } .subscribe({ - viewModelScope.launch { historyController.fetchChats() } + scope.launch { historyController.fetchChats() } }, { scannedRendezvous.remove(payload.rendezvous.publicKey) ErrorUtils.handleError(it) @@ -1500,7 +1631,7 @@ class Session @Inject constructor( fun onCodeScan( code: ScannableKikCode, ) { - if (uiFlow.value.billState.bill == null) { + if (state.value.billState.bill == null) { if (code is ScannableKikCode.RemoteKikCode) { onCodeScan(code.payloadId) } @@ -1514,7 +1645,7 @@ class Session @Inject constructor( @SuppressLint("CheckResult") fun onRemoteSend() { - val bill = uiFlow.value.billState.bill + val bill = state.value.billState.bill when (bill) { is Bill.Cash -> { shareGiftCard() @@ -1544,12 +1675,12 @@ class Session @Inject constructor( giftCard = giftCard ) .doOnSubscribe { - uiFlow.update { it.copy(isRemoteSendLoading = true) } + state.update { it.copy(isRemoteSendLoading = true) } } .doOnComplete { loadingIndicatorTimer?.cancel() loadingIndicatorTimer = Timer().schedule(1000) { - uiFlow.update { it.copy(isRemoteSendLoading = false) } + state.update { it.copy(isRemoteSendLoading = false) } } analytics.remoteSendOutgoing( @@ -1559,7 +1690,7 @@ class Session @Inject constructor( } .doOnError { loadingIndicatorTimer?.cancel() - uiFlow.update { it.copy(isRemoteSendLoading = false) } + state.update { it.copy(isRemoteSendLoading = false) } } .timeout(15, TimeUnit.SECONDS) .subscribe( @@ -1568,7 +1699,7 @@ class Session @Inject constructor( ) } - private fun shareTipCard() = viewModelScope.launch { + private fun shareTipCard() = scope.launch { val connectedAccount = tipController.connectedAccount.value ?: return@launch withContext(Dispatchers.Main) { val shareIntent = @@ -1579,7 +1710,7 @@ class Session @Inject constructor( } private fun cancelRemoteSend(giftCard: GiftCardAccount, amount: KinAmount) = - viewModelScope.launch { + scope.launch { val organizer = SessionManager.getOrganizer() ?: return@launch client.cancelRemoteSend(giftCard, amount.kin, organizer) .onSuccess { @@ -1613,11 +1744,11 @@ class Session @Inject constructor( BottomBarManager.showMessage( BottomBarManager.BottomBarMessage( - title = getString(R.string.prompt_title_didYouSendLink), - subtitle = getString(R.string.prompt_description_didYouSendLink), - positiveText = getString(R.string.action_yes), - negativeText = getString(R.string.action_noTryAgain), - tertiaryText = getString(R.string.action_cancelSend), + title = resources.getString(R.string.prompt_title_didYouSendLink), + subtitle = resources.getString(R.string.prompt_description_didYouSendLink), + positiveText = resources.getString(R.string.action_yes), + negativeText = resources.getString(R.string.action_noTryAgain), + tertiaryText = resources.getString(R.string.action_cancelSend), onPositive = { cancelSend(style = PresentationStyle.Pop) vibrator.vibrate() @@ -1645,10 +1776,10 @@ class Session @Inject constructor( if (request != null) { when { request.paymentRequest != null -> { - viewModelScope.launch { - if (uiFlow.value.balance == null) { + scope.launch { + if (state.value.balance == null) { balanceController.fetchBalanceSuspend() - uiFlow.update { + state.update { val amount = KinAmount.newInstance( Kin.fromKin(balanceController.rawBalance), exchange.localRate ) @@ -1733,7 +1864,7 @@ class Session @Inject constructor( val mnemonic = mnemonicManager.fromEntropyBase58(base58Entropy) val giftCardAccount = giftCardManager.createGiftCard(mnemonic) - viewModelScope.launch { + scope.launch { withContext(Dispatchers.IO) { withTimeout(15000) { balanceController.fetchBalanceSuspend() @@ -1750,7 +1881,7 @@ class Session @Inject constructor( historyController.fetchChats() - viewModelScope.launch(Dispatchers.Main) { + scope.launch(Dispatchers.Main) { BottomBarManager.clear() showBill( Bill.Cash( @@ -1780,20 +1911,20 @@ class Session @Inject constructor( when (throwable) { is RemoteSendException.GiftCardClaimedException -> TopBarManager.showMessage( - getString(R.string.error_title_alreadyCollected), - getString(R.string.error_description_alreadyCollected) + resources.getString(R.string.error_title_alreadyCollected), + resources.getString(R.string.error_description_alreadyCollected) ) is RemoteSendException.GiftCardExpiredException -> TopBarManager.showMessage( - getString(R.string.error_title_linkExpired), - getString(R.string.error_description_linkExpired) + resources.getString(R.string.error_title_linkExpired), + resources.getString(R.string.error_description_linkExpired) ) else -> { TopBarManager.showMessage( - getString(R.string.error_title_failedToCollect), - getString(R.string.error_description_failedToCollect) + resources.getString(R.string.error_title_failedToCollect), + resources.getString(R.string.error_description_failedToCollect) ) val traceableError = Throwable( message = "Failed to receive remote send", diff --git a/app/src/main/java/com/getcode/inject/DataModule.kt b/app/src/main/java/com/getcode/inject/DataModule.kt index b02ccb473..b7247b164 100644 --- a/app/src/main/java/com/getcode/inject/DataModule.kt +++ b/app/src/main/java/com/getcode/inject/DataModule.kt @@ -5,6 +5,7 @@ import com.getcode.mapper.ConversationMessageWithContentMapper import com.getcode.network.ConversationController import com.getcode.network.ConversationStreamController import com.getcode.network.ChatHistoryController +import com.getcode.network.TipController import com.getcode.network.exchange.Exchange import com.getcode.network.service.ChatServiceV2 import dagger.Module @@ -25,11 +26,13 @@ object DataModule { exchange: Exchange, conversationMapper: ConversationMapper, messageWithContentMapper: ConversationMessageWithContentMapper, + tipController: TipController, ): ConversationController = ConversationStreamController( historyController = historyController, exchange = exchange, chatService = chatServiceV2, conversationMapper = conversationMapper, - messageWithContentMapper = messageWithContentMapper + messageWithContentMapper = messageWithContentMapper, + tipController = tipController ) } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/models/BillState.kt b/app/src/main/java/com/getcode/models/BillState.kt index c41c9fc64..32620e5a1 100644 --- a/app/src/main/java/com/getcode/models/BillState.kt +++ b/app/src/main/java/com/getcode/models/BillState.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.getcode.R import com.getcode.model.CodePayload -import com.getcode.model.TipMetadata +import com.getcode.model.SocialUser import com.getcode.model.Domain import com.getcode.model.Kin import com.getcode.model.KinAmount @@ -21,7 +21,7 @@ data class BillState( val valuation: Valuation?, val paymentConfirmation: PaymentConfirmation?, val loginConfirmation: LoginConfirmation?, - val tipConfirmation: TipConfirmation?, + val socialUserPaymentConfirmation: SocialUserPaymentConfirmation?, val primaryAction: Action?, val secondaryAction: Action?, ) { @@ -39,7 +39,7 @@ data class BillState( valuation = null, paymentConfirmation = null, loginConfirmation = null, - tipConfirmation = null, + socialUserPaymentConfirmation = null, primaryAction = null, secondaryAction = null, ) @@ -190,25 +190,34 @@ data class BillToast( .toString() } +sealed class Confirmation( + open val showScrim: Boolean = false, + open val state: ConfirmationState, +) + data class PaymentConfirmation( - val state: ConfirmationState, + override val state: ConfirmationState, val payload: CodePayload, val requestedAmount: KinAmount, val localAmount: KinAmount, -) + override val showScrim: Boolean = false, +): Confirmation(showScrim, state) data class LoginConfirmation( - val state: ConfirmationState, + override val state: ConfirmationState, val payload: CodePayload, val domain: Domain, -) + override val showScrim: Boolean = false, +): Confirmation(showScrim, state) -data class TipConfirmation( - val state: ConfirmationState, +data class SocialUserPaymentConfirmation( + override val state: ConfirmationState, val amount: KinAmount, val payload: CodePayload?, - val metadata: TipMetadata, -) { + val metadata: SocialUser, + val isPrivate: Boolean = false, + override val showScrim: Boolean = false, +): Confirmation(showScrim, state) { val imageUrl: String? get() { return when (metadata) { diff --git a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt index 6e1964f86..d1b1fbaf5 100644 --- a/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/ChatScreens.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.zIndex import androidx.paging.compose.collectAsLazyPagingItems +import cafe.adriel.voyager.core.lifecycle.LifecycleEffect import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.hilt.getViewModel @@ -33,8 +34,10 @@ import com.getcode.model.chat.Reference import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.chat.UserAvatar +import com.getcode.ui.utils.getActivityScopedViewModel import com.getcode.util.formatDateRelatively -import com.getcode.view.main.chat.conversation.ChatConversationScreen +import com.getcode.view.main.balance.BalanceSheetViewModel +import com.getcode.view.main.chat.conversation.ConversationScreen import com.getcode.view.main.chat.conversation.ConversationViewModel import com.getcode.view.main.chat.create.byusername.ChatByUsernameScreen import com.getcode.view.main.chat.list.ChatListScreen @@ -57,13 +60,22 @@ data object ChatListModal: ChatGraph, ModalRoot { @Composable override fun Content() { + val navigator = LocalCodeNavigator.current + val viewModel = getActivityScopedViewModel() ModalContainer( closeButtonEnabled = { it is ChatListModal }, ) { - val viewModel = getViewModel() - val conversations = viewModel.conversations.collectAsLazyPagingItems() - ChatListScreen(viewModel, conversations) + ChatListScreen(viewModel) } + + LifecycleEffect( + onStarted = { + val disposedScreen = navigator.lastItem + if (disposedScreen !is BalanceModal) { + viewModel.dispatchEvent(ChatListViewModel.Event.OnOpened) + } + } + ) } } @@ -86,7 +98,7 @@ data object ChatByUsernameScreen: ChatGraph, ModalContent { } @Parcelize -data class ChatMessageConversationScreen( +data class ConversationScreen( val user: @RawValue TwitterUser? = null, val chatId: ID? = null, val intentId: ID? = null @@ -154,17 +166,13 @@ data class ChatMessageConversationScreen( } }, - backButtonEnabled = { it is ChatMessageConversationScreen }, + backButtonEnabled = { it is ConversationScreen }, onBackClicked = { - if (state.twitterUser != null) { - navigator.popUntil { it is ChatListModal } - } else { - navigator.pop() - } + navigator.popUntil { it is ChatListModal } } ) { val messages = vm.messages.collectAsLazyPagingItems() - ChatConversationScreen(state, messages, vm::dispatchEvent) + ConversationScreen(state, messages, vm::dispatchEvent) } LaunchedEffect(vm) { @@ -180,9 +188,11 @@ data class ChatMessageConversationScreen( vm.eventFlow .filterIsInstance() .onEach { - Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() + if (it.show) { + Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() + } if (it.fatal) { - navigator.pop() + navigator.popAll() } }.launchIn(this) } @@ -202,13 +212,5 @@ data class ChatMessageConversationScreen( ) } } - - LaunchedEffect(intentId) { - if (intentId != null) { - vm.dispatchEvent( - ConversationViewModel.Event.OnReferenceChanged(Reference.IntentId(intentId)) - ) - } - } } } \ No newline at end of file diff --git a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt index 2a4a1ac80..f2284b8c7 100644 --- a/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt +++ b/app/src/main/java/com/getcode/navigation/screens/MainScreens.kt @@ -295,7 +295,7 @@ data class ChatScreen(val chatId: ID) : MainGraph, ModalContent { .map { it.reference } .filterIsInstance() .map { it.id } - .onEach { navigator.push(ChatMessageConversationScreen(intentId = it)) } + .onEach { navigator.push(ConversationScreen(intentId = it)) } .launchIn(this) } diff --git a/app/src/main/java/com/getcode/ui/components/Modal.kt b/app/src/main/java/com/getcode/ui/components/Modal.kt index 115a08503..f62383fd9 100644 --- a/app/src/main/java/com/getcode/ui/components/Modal.kt +++ b/app/src/main/java/com/getcode/ui/components/Modal.kt @@ -1,6 +1,5 @@ package com.getcode.ui.components -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope diff --git a/app/src/main/java/com/getcode/ui/components/TextInput.kt b/app/src/main/java/com/getcode/ui/components/TextInput.kt index 945194c00..0d47b86ca 100644 --- a/app/src/main/java/com/getcode/ui/components/TextInput.kt +++ b/app/src/main/java/com/getcode/ui/components/TextInput.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.layout import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.AnnotatedString @@ -48,6 +49,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat @@ -58,6 +60,7 @@ import com.getcode.theme.extraSmall import com.getcode.theme.inputColors import com.getcode.ui.utils.AutoSizeTextMeasurer import com.getcode.ui.utils.addIf +import com.getcode.ui.utils.measured import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlin.math.roundToInt @@ -101,9 +104,11 @@ fun TextInput( interactionSource = remember { MutableInteractionSource() } ) + val density = LocalDensity.current var textSize by remember { mutableStateOf(style.fontSize) } + var textFieldSize by remember { mutableStateOf(DpSize.Zero) } - BoxWithConstraints(modifier = modifier) { + Box(modifier = modifier.measured { textFieldSize = it }) { BasicTextField2( modifier = Modifier .background(backgroundColor, shape) @@ -112,7 +117,12 @@ fun TextInput( mode = constraintMode, state = state, style = style, - frameConstraints = constraints + frameConstraints = Constraints( + minWidth = 0, + minHeight = 0, + maxWidth = with (density) { textFieldSize.width.roundToPx() }, + maxHeight = with (density) { textFieldSize.height.roundToPx() }, + ) ) { textSize = it }, enabled = enabled, readOnly = readOnly, diff --git a/app/src/main/java/com/getcode/ui/components/TwitterUsernameDisplay.kt b/app/src/main/java/com/getcode/ui/components/TwitterUsernameDisplay.kt index 2fc6af604..bda8319a8 100644 --- a/app/src/main/java/com/getcode/ui/components/TwitterUsernameDisplay.kt +++ b/app/src/main/java/com/getcode/ui/components/TwitterUsernameDisplay.kt @@ -32,7 +32,7 @@ fun TwitterUsernameDisplay( painter = rememberVectorPainter(image = ImageVector.vectorResource(id = R.drawable.ic_twitter_x)), contentDescription = null ) - Text(text = username, style = CodeTheme.typography.textLarge) + Text(text = username, style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain) verificationStatus?.let { status -> status.checkmark()?.let { asset -> Image( diff --git a/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt b/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt index 7f814b204..6dcfac8a5 100644 --- a/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt +++ b/app/src/main/java/com/getcode/ui/components/chat/utils/HandleMessageChanges.kt @@ -10,11 +10,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.paging.compose.LazyPagingItems -import com.getcode.model.chat.MessageContent import com.getcode.ui.utils.isScrolledToTheBeginning import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map @@ -23,7 +21,7 @@ import kotlinx.coroutines.flow.map internal fun HandleMessageChanges( listState: LazyListState, items: LazyPagingItems, - onIncomingMessageReceivedBelowFold: (ChatItem.Message) -> Unit, + onMessageDelivered: (ChatItem.Message) -> Unit, ) { var lastMessageSent by rememberSaveable { mutableLongStateOf(0L) @@ -35,7 +33,7 @@ internal fun HandleMessageChanges( // handle incoming/outgoing messages - scroll to bottom to reset view in the following circumstances: // 1) New message is from self (e.g outgoing) // 2) New message is from participant and we are already at the bottom (to prevent rug pull) - LaunchedEffect(Unit) { + LaunchedEffect(listState) { snapshotFlow { items.itemSnapshotList } .map { it.firstOrNull() } .filterNotNull() @@ -51,18 +49,22 @@ internal fun HandleMessageChanges( } } else { listState.handleAndReplayAfter(300) { - if (listState.isScrolledToTheBeginning() && newMessage.date.toEpochMilliseconds() > lastMessageReceived) { - // Android 10 we have to utilize a mimic for IME nested scrolling - // using the [LazyListState#isScrollInProgress] which animateScrollToItem triggers - // thus causing the IME to be dismissed when we trigger the sync. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - listState.scrollToItem(0) - } else { - listState.animateScrollToItem(0) + if (newMessage.date.toEpochMilliseconds() > lastMessageReceived) { + if (listState.isScrolledToTheBeginning()) { + // Android 10 we have to utilize a mimic for IME nested scrolling + // using the [LazyListState#isScrollInProgress] which animateScrollToItem triggers + // thus causing the IME to be dismissed when we trigger the sync. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + listState.scrollToItem(0) + } else { + listState.animateScrollToItem(0) + } } - } else { - onIncomingMessageReceivedBelowFold(newMessage) + + onMessageDelivered(newMessage) + lastMessageReceived = newMessage.date.toEpochMilliseconds() } + lastMessageReceived = newMessage.date.toEpochMilliseconds() } } diff --git a/app/src/main/java/com/getcode/ui/modals/Confirmations.kt b/app/src/main/java/com/getcode/ui/modals/Confirmations.kt new file mode 100644 index 000000000..120bed617 --- /dev/null +++ b/app/src/main/java/com/getcode/ui/modals/Confirmations.kt @@ -0,0 +1,157 @@ +package com.getcode.ui.modals + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment.Companion.BottomCenter +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.zIndex +import cafe.adriel.voyager.navigator.currentOrThrow +import com.getcode.LocalSession +import com.getcode.R +import com.getcode.manager.TopBarManager +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.screens.BuyMoreKinModal +import com.getcode.navigation.screens.BuySellScreen +import com.getcode.theme.Black40 +import com.getcode.ui.utils.AnimationUtils +import com.getcode.ui.utils.ModalAnimationSpeed +import com.getcode.ui.utils.rememberedClickable + +@Composable +fun ConfirmationModals( + modifier: Modifier = Modifier, +) { + val navigator = LocalCodeNavigator.current + val context = LocalContext.current + val session = LocalSession.currentOrThrow + val sessionState by session.state.collectAsState() + Box(modifier = modifier) { + val billState by rememberUpdatedState(sessionState.billState) + + val showScrim by remember(billState) { + derivedStateOf { + val loginConfirmation = billState.loginConfirmation + val paymentConfirmation = billState.paymentConfirmation + val socialPaymentConfirmation = billState.socialUserPaymentConfirmation + + listOf(loginConfirmation, paymentConfirmation, socialPaymentConfirmation).any { + it?.showScrim == true + } + } + } + + val scrimAlpha by animateFloatAsState(if (showScrim) 1f else 0f, label = "scrim visibility") + + if (showScrim) { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(scrimAlpha) + .background(Black40) + .rememberedClickable(indication = null, + interactionSource = remember { MutableInteractionSource() }) {} + ) + } + + // Payment Confirmation container + AnimatedContent( + modifier = Modifier.align(BottomCenter), + targetState = sessionState.billState.paymentConfirmation?.payload, // payload is constant across state changes + transitionSpec = AnimationUtils.modalAnimationSpec(), + label = "payment confirmation", + ) { + if (it != null) { + Box( + contentAlignment = BottomCenter + ) { + PaymentConfirmation( + confirmation = sessionState.billState.paymentConfirmation, + balance = sessionState.balance, + onAddKin = { + session.rejectPayment() + if (sessionState.buyModule.enabled) { + if (sessionState.buyModule.available) { + navigator.show(BuyMoreKinModal(showClose = true)) + } else { + TopBarManager.showMessage( + TopBarManager.TopBarMessage( + title = context.getString(R.string.error_title_buyModuleUnavailable), + message = context.getString(R.string.error_description_buyModuleUnavailable), + type = TopBarManager.TopBarMessageType.ERROR + ) + ) + } + } else { + navigator.show(BuySellScreen) + } + }, + onSend = { session.completePayment() }, + onCancel = { + session.rejectPayment() + } + ) + } + } + } + + // Login Confirmation container + AnimatedContent( + modifier = Modifier.align(BottomCenter), + targetState = sessionState.billState.loginConfirmation?.payload, // payload is constant across state changes + transitionSpec = AnimationUtils.modalAnimationSpec(), + label = "login confirmation", + ) { + if (it != null) { + Box( + contentAlignment = BottomCenter + ) { + LoginConfirmation( + confirmation = sessionState.billState.loginConfirmation, + onSend = { session.completeLogin() }, + onCancel = { + session.rejectLogin() + } + ) + } + } + } + + // Social Payment Confirmation container + AnimatedContent( + modifier = Modifier.align(BottomCenter), + targetState = sessionState.billState.socialUserPaymentConfirmation?.payload, // payload is constant across state changes + transitionSpec = AnimationUtils.modalAnimationSpec(speed = ModalAnimationSpeed.Fast), + label = "tip confirmation", + ) { + if (it != null) { + Box( + contentAlignment = BottomCenter + ) { + TipConfirmation( + confirmation = sessionState.billState.socialUserPaymentConfirmation, + onSend = { + if (sessionState.billState.socialUserPaymentConfirmation?.isPrivate == true) { + session.completePrivatePayment() + } else { + session.completeTipPayment() + } + }, + onCancel = { session.cancelTip() } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/scanner/modals/LoginConfirmation.kt b/app/src/main/java/com/getcode/ui/modals/LoginConfirmation.kt similarity index 98% rename from app/src/main/java/com/getcode/view/main/scanner/modals/LoginConfirmation.kt rename to app/src/main/java/com/getcode/ui/modals/LoginConfirmation.kt index d1e0a9853..6a32aedf4 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/modals/LoginConfirmation.kt +++ b/app/src/main/java/com/getcode/ui/modals/LoginConfirmation.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.scanner.modals +package com.getcode.ui.modals import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/com/getcode/view/main/scanner/modals/PaymentConfirmation.kt b/app/src/main/java/com/getcode/ui/modals/PaymentConfirmation.kt similarity index 99% rename from app/src/main/java/com/getcode/view/main/scanner/modals/PaymentConfirmation.kt rename to app/src/main/java/com/getcode/ui/modals/PaymentConfirmation.kt index e0d6ea896..33277a57e 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/modals/PaymentConfirmation.kt +++ b/app/src/main/java/com/getcode/ui/modals/PaymentConfirmation.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.scanner.modals +package com.getcode.ui.modals import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope diff --git a/app/src/main/java/com/getcode/view/main/scanner/modals/ReceivedKinConfirmation.kt b/app/src/main/java/com/getcode/ui/modals/ReceivedKinConfirmation.kt similarity index 97% rename from app/src/main/java/com/getcode/view/main/scanner/modals/ReceivedKinConfirmation.kt rename to app/src/main/java/com/getcode/ui/modals/ReceivedKinConfirmation.kt index f60bbc495..f7dba4b29 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/modals/ReceivedKinConfirmation.kt +++ b/app/src/main/java/com/getcode/ui/modals/ReceivedKinConfirmation.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.scanner.modals +package com.getcode.ui.modals import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/com/getcode/view/main/scanner/modals/TipConfirmation.kt b/app/src/main/java/com/getcode/ui/modals/TipConfirmation.kt similarity index 96% rename from app/src/main/java/com/getcode/view/main/scanner/modals/TipConfirmation.kt rename to app/src/main/java/com/getcode/ui/modals/TipConfirmation.kt index 3454bf1f0..6d160528b 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/modals/TipConfirmation.kt +++ b/app/src/main/java/com/getcode/ui/modals/TipConfirmation.kt @@ -1,4 +1,4 @@ -package com.getcode.view.main.scanner.modals +package com.getcode.ui.modals import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.fillMaxWidth @@ -24,7 +24,7 @@ import coil3.request.error import com.getcode.R import com.getcode.model.TwitterUser import com.getcode.models.ConfirmationState -import com.getcode.models.TipConfirmation +import com.getcode.models.SocialUserPaymentConfirmation import com.getcode.theme.CodeTheme import com.getcode.theme.White10 import com.getcode.theme.bolded @@ -38,7 +38,7 @@ import com.getcode.view.main.scanner.components.PriceWithFlag @Composable internal fun TipConfirmation( modifier: Modifier = Modifier, - confirmation: TipConfirmation?, + confirmation: SocialUserPaymentConfirmation?, onSend: () -> Unit, onCancel: () -> Unit, ) { diff --git a/app/src/main/java/com/getcode/view/MainActivity.kt b/app/src/main/java/com/getcode/view/MainActivity.kt index 1cdb0e92d..d35c87e2a 100644 --- a/app/src/main/java/com/getcode/view/MainActivity.kt +++ b/app/src/main/java/com/getcode/view/MainActivity.kt @@ -20,7 +20,7 @@ import com.getcode.LocalNetworkObserver import com.getcode.LocalPhoneFormatter import com.getcode.LocalSession import com.getcode.R -import com.getcode.Session +import com.getcode.SessionController import com.getcode.analytics.AnalyticsService import com.getcode.network.TipController import com.getcode.network.client.Client @@ -77,7 +77,8 @@ class MainActivity : FragmentActivity() { @Inject lateinit var tipDefinitions: DefinedTips - private val session by viewModels() + @Inject + lateinit var sessionController: SessionController /** * The compose navigation controller does not play nice with single task activities. @@ -121,7 +122,7 @@ class MainActivity : FragmentActivity() { ) CompositionLocalProvider( - LocalSession provides session, + LocalSession provides sessionController, LocalAnalytics provides analyticsManager, LocalDeeplinks provides deeplinkHandler, LocalNetworkObserver provides networkObserver, diff --git a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt index 4b491063e..59d27e441 100644 --- a/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt +++ b/app/src/main/java/com/getcode/view/main/balance/BalanceSheet.kt @@ -44,7 +44,7 @@ import com.getcode.model.chat.Chat import com.getcode.model.chat.isConversation import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.BuyMoreKinModal -import com.getcode.navigation.screens.ChatMessageConversationScreen +import com.getcode.navigation.screens.ConversationScreen import com.getcode.navigation.screens.ChatScreen import com.getcode.navigation.screens.CurrencySelectionModal import com.getcode.navigation.screens.FaqScreen @@ -87,7 +87,7 @@ fun BalanceScreen( faqOpen = { navigator.push(FaqScreen) }, openChat = { if (it.isConversation) { - navigator.push(ChatMessageConversationScreen(chatId = it.id)) + navigator.push(ConversationScreen(chatId = it.id)) } else { navigator.push(ChatScreen(it.id)) } @@ -186,7 +186,8 @@ fun BalanceContent( itemsIndexed( state.chats, key = { _, item -> item.id }, - contentType = { _, item -> item }) { index, chat -> + contentType = { _, item -> item } + ) { index, chat -> ChatNode(chat = chat, onClick = { openChat(chat) }) Divider( modifier = Modifier.padding(start = CodeTheme.dimens.inset), diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationScreen.kt similarity index 94% rename from app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt rename to app/src/main/java/com/getcode/view/main/chat/conversation/ConversationScreen.kt index 4c54925ab..694fcf93e 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ChatConversationScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -29,6 +30,7 @@ import androidx.paging.compose.LazyPagingItems import cafe.adriel.voyager.navigator.currentOrThrow import com.getcode.LocalSession import com.getcode.R +import com.getcode.SessionEvent import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.ConnectAccount import com.getcode.theme.CodeTheme @@ -43,15 +45,19 @@ import com.getcode.ui.components.chat.utils.HandleMessageChanges import com.getcode.util.formatted import com.getcode.view.main.tip.IdentityConnectionReason import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.util.UUID @Composable -fun ChatConversationScreen( +fun ConversationScreen( state: ConversationViewModel.State, messages: LazyPagingItems, dispatchEvent: (ConversationViewModel.Event) -> Unit, ) { val navigator = LocalCodeNavigator.current - val session = LocalSession.currentOrThrow CodeScaffold( topBar = { @@ -90,7 +96,7 @@ fun ChatConversationScreen( state.costToChat.formatted(suffix = "") ) ) { -// session.presentTipConfirmation(state.costToChat) + dispatchEvent(ConversationViewModel.Event.PresentPaymentConfirmation) } } } diff --git a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt index 535e14778..c10ea138d 100644 --- a/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/conversation/ConversationViewModel.kt @@ -12,7 +12,10 @@ import androidx.paging.map import com.codeinc.gen.user.v1.user import com.getcode.BuildConfig import com.getcode.R +import com.getcode.SessionController +import com.getcode.SessionEvent import com.getcode.manager.BottomBarManager +import com.getcode.manager.TopBarManager import com.getcode.model.ConversationWithLastPointers import com.getcode.model.Feature import com.getcode.model.ID @@ -21,6 +24,7 @@ import com.getcode.model.ConversationCashFeature import com.getcode.model.CurrencyCode import com.getcode.model.Fiat import com.getcode.model.KinAmount +import com.getcode.model.SocialUser import com.getcode.model.TwitterUser import com.getcode.model.chat.ChatMember import com.getcode.model.chat.ChatType @@ -31,18 +35,24 @@ import com.getcode.network.ConversationController import com.getcode.network.TipController import com.getcode.network.exchange.Exchange import com.getcode.network.repository.FeatureRepository +import com.getcode.solana.keys.PublicKey import com.getcode.ui.components.chat.utils.ChatItem import com.getcode.ui.components.chat.utils.ConversationMessageIndice import com.getcode.util.resources.ResourceHelper import com.getcode.util.toInstantFromMillis import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.bytes +import com.getcode.utils.catchSafely import com.getcode.utils.timestamp +import com.getcode.utils.trace import com.getcode.view.BaseViewModel2 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull @@ -52,10 +62,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.datetime.Instant import timber.log.Timber import java.util.UUID import javax.inject.Inject +import kotlin.coroutines.resume import kotlin.math.cos @HiltViewModel @@ -65,6 +77,7 @@ class ConversationViewModel @Inject constructor( tipController: TipController, exchange: Exchange, resources: ResourceHelper, + sessionController: SessionController, ) : BaseViewModel2( initialState = State.Default, updateStateForEvent = updateStateForEvent @@ -111,10 +124,9 @@ class ConversationViewModel @Inject constructor( sealed interface Event { data class OnTwitterUserChanged(val user: TwitterUser?) : Event - data class OnCostToChatChanged(val cost: KinAmount): Event - data class OnMembersChanged(val members: List): Event + data class OnCostToChatChanged(val cost: KinAmount) : Event + data class OnMembersChanged(val members: List) : Event data class OnChatIdChanged(val chatId: ID?) : Event - data class OnReferenceChanged(val reference: Reference.IntentId?) : Event data class OnConversationChanged(val conversationWithPointers: ConversationWithLastPointers) : Event @@ -131,14 +143,17 @@ class ConversationViewModel @Inject constructor( data object SendMessage : Event data object RevealIdentity : Event - data class OnIdentityAvailable(val available: Boolean): Event + data class OnIdentityAvailable(val available: Boolean) : Event data object OnIdentityRevealed : Event data class OnPointersUpdated(val pointers: Map) : Event data class MarkRead(val messageId: ID) : Event data class MarkDelivered(val messageId: ID) : Event - data class Error(val message: String, val fatal: Boolean) : Event + data object PresentPaymentConfirmation : Event + + data class Error(val fatal: Boolean, val message: String = "", val show: Boolean = true) : + Event } init { @@ -178,34 +193,52 @@ class ConversationViewModel @Inject constructor( } .launchIn(viewModelScope) - // reference ID is used to create a chat that is non-existent if needed eventFlow - .filterIsInstance() - .map { it.reference } - .filterNotNull() - .filterIsInstance() - .map { it.id } - .distinctUntilChanged() - .mapNotNull { referenceId -> + .filterIsInstance() + .mapNotNull { + val state = stateFlow.value + if (state.twitterUser == null) return@mapNotNull null + state.twitterUser to state.costToChat + }.onEach { (user, amount) -> + sessionController.presentPrivatePaymentConfirmation( + socialUser = user, + amount = amount + ) + }.launchIn(viewModelScope) + + sessionController.eventFlow + .filterIsInstance() + .onEach { event -> runCatching { - conversationController.getOrCreateConversation(referenceId, ChatType.TwoWay) + val conversation = conversationController.getOrCreateConversation( + identifier = event.intentId, + with = event.user + ) + dispatchEvent(Event.OnConversationChanged(conversation)) }.onFailure { it.printStackTrace() + TopBarManager.showMessage( + "Failed to Start Chat", + "We were unable to start a chat with ${event.user.username}. Please try again.", + ) + dispatchEvent( Event.Error( message = if (BuildConfig.DEBUG) it.message.orEmpty() else "Failed to create conversation", + show = false, fatal = true ) ) }.getOrNull() } - .onEach { dispatchEvent(Dispatchers.Main, Event.OnConversationChanged(it)) } .launchIn(viewModelScope) eventFlow .filterIsInstance() .map { it.conversationWithPointers } - .onEach { (conversation, pointer) -> + .distinctUntilChangedBy { it.conversation.id } + .onEach { conversationController.resetUnreadCount(it.conversation.id) } + .onEach { (conversation, _) -> runCatching { conversationController.openChatStream(viewModelScope, conversation) }.onFailure { @@ -261,8 +294,25 @@ class ConversationViewModel @Inject constructor( val textFieldState = it.textFieldState val text = textFieldState.text.toString() textFieldState.clearText() + conversationController.sendMessage(it.conversationId!!, text) - }.launchIn(viewModelScope) + .onSuccess { + trace( + tag = "Conversation", + message = "message sent successfully", + type = TraceType.Silent + ) + } + .onFailure { error -> + trace( + tag = "Conversation", + message = "message failed to send", + type = TraceType.Error, + error = error + ) + } + } + .launchIn(viewModelScope) eventFlow .filterIsInstance() @@ -313,7 +363,13 @@ class ConversationViewModel @Inject constructor( } .onEach { (result, user) -> if (result != null) { - dispatchEvent(Event.OnUserRevealed(memberId = user.memberId, result.username, result.imageUrl)) + dispatchEvent( + Event.OnUserRevealed( + memberId = user.memberId, + result.username, + result.imageUrl + ) + ) } } .launchIn(viewModelScope) @@ -374,6 +430,7 @@ class ConversationViewModel @Inject constructor( conversationId = conversation.id, identityRevealed = conversation.hasRevealedIdentity, pointers = event.conversationWithPointers.pointers, + twitterUser = null, users = members.map { State.User( memberId = it.id, @@ -409,6 +466,8 @@ class ConversationViewModel @Inject constructor( is Event.OnTwitterUserChanged -> { state -> state.copy(twitterUser = event.user) } + + is Event.PresentPaymentConfirmation, is Event.OnChatIdChanged, is Event.Error, Event.RevealIdentity, @@ -417,10 +476,6 @@ class ConversationViewModel @Inject constructor( is Event.MarkDelivered, is Event.SendMessage -> { state -> state } - is Event.OnReferenceChanged -> { state -> - state.copy(reference = event.reference) - } - is Event.OnIdentityRevealed -> { state -> state.copy(identityRevealed = true) } diff --git a/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameScreen.kt b/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameScreen.kt index 88b18c292..693f474a6 100644 --- a/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/create/byusername/ChatByUsernameScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator -import com.getcode.navigation.screens.ChatMessageConversationScreen +import com.getcode.navigation.screens.ConversationScreen import com.getcode.theme.CodeTheme import com.getcode.theme.inputColors import com.getcode.ui.components.ConstraintMode @@ -70,7 +70,7 @@ fun ChatByUsernameScreen( .filterIsInstance() .map { it.user } .onEach { - navigator.push(ChatMessageConversationScreen(user = it)) + navigator.push(ConversationScreen(user = it)) }.launchIn(this) } diff --git a/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt b/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt index 2e6401c65..22e4c6c05 100644 --- a/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt +++ b/app/src/main/java/com/getcode/view/main/chat/list/ChatListScreen.kt @@ -1,27 +1,46 @@ package com.getcode.view.main.chat.list +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.paging.compose.LazyPagingItems -import com.getcode.model.chat.Chat +import androidx.compose.ui.text.style.TextAlign import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.ChatByUsernameScreen +import com.getcode.navigation.screens.ConversationScreen import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 import com.getcode.ui.components.ButtonState import com.getcode.ui.components.CodeButton +import com.getcode.ui.components.CodeCircularProgressIndicator import com.getcode.ui.components.CodeScaffold import com.getcode.ui.components.chat.ChatNode @Composable fun ChatListScreen( viewModel: ChatListViewModel, - conversations: LazyPagingItems ) { + val state by viewModel.stateFlow.collectAsState() val navigator = LocalCodeNavigator.current + + val chatsEmpty by remember(state.chats) { + derivedStateOf { state.chats.isEmpty() } + } + CodeScaffold( bottomBar = { Box(modifier = Modifier.fillMaxWidth()) { @@ -36,9 +55,54 @@ fun ChatListScreen( } ) { padding -> LazyColumn(modifier = Modifier.padding(padding)) { - items(conversations.itemCount) { index -> - conversations[index]?.let { chat -> - ChatNode(chat = chat) { } + items(state.chats, key = { it.id }) { chat -> + ChatNode(chat = chat) { + navigator.push(ConversationScreen(chatId = chat.id)) + } + Divider( + modifier = Modifier.padding(start = CodeTheme.dimens.inset), + color = White10, + ) + } + + when { + state.loading -> { + item { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x2, + CenterVertically + ), + ) { + CodeCircularProgressIndicator() + Text( + modifier = Modifier.fillMaxWidth(0.6f), + text = "Loading your chats", + textAlign = TextAlign.Center + ) + } + } + } + chatsEmpty -> { + item { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x2, + CenterVertically + ), + ) { + Text( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x1), + text = "You don't have any chats yet.", + color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textMedium + ) + } + } } } } diff --git a/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt b/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt index 04a662c28..4820aad1c 100644 --- a/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/chat/list/ChatListViewModel.kt @@ -1,31 +1,75 @@ package com.getcode.view.main.chat.list +import androidx.lifecycle.viewModelScope +import com.getcode.model.chat.Chat import com.getcode.network.ConversationListController +import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.view.BaseViewModel2 +import com.getcode.view.main.balance.BalanceSheetViewModel.Event import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class ChatListViewModel @Inject constructor( conversationsController: ConversationListController, + networkObserver: NetworkConnectivityListener, ): BaseViewModel2( initialState = State(), updateStateForEvent = updateStateForEvent ) { data class State( - val x: String = "" + val loading: Boolean = false, + val chats: List = emptyList(), ) sealed interface Event { - data object Noop: Event + data class OnChatsLoading(val loading: Boolean) : Event + data class OnChatsUpdated(val chats: List) : Event + data object OnOpened: Event } - val conversations = conversationsController.observeConversations() + init { + conversationsController.observeConversations() + .onEach { + if (it == null || (it.isEmpty() && !networkObserver.isConnected)) { + dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(true)) + } + } + .map { chats -> + when { + chats == null -> null // await for confirmation it's empty + chats.isEmpty() && !networkObserver.isConnected -> null // remain loading while disconnected + conversationsController.isLoadingChats -> null // remain loading while fetching messages + else -> chats + } + } + .filterNotNull() + .onEach { update -> + dispatchEvent(Dispatchers.Main, Event.OnChatsUpdated(update)) + }.onEach { + dispatchEvent(Dispatchers.Main, Event.OnChatsLoading(false)) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { conversationsController.fetchChats() } + .launchIn(viewModelScope) + } companion object { val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - Event.Noop -> { state -> state } + is Event.OnOpened -> { state -> state } + is Event.OnChatsLoading -> { state -> state.copy(loading = event.loading) } + is Event.OnChatsUpdated -> { state -> state.copy(chats = event.chats) } } } } diff --git a/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt b/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt index 6c4bd3f1c..ee03a0244 100644 --- a/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt +++ b/app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt @@ -6,7 +6,6 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalAnimationApi @@ -43,7 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import com.getcode.SessionEvent import com.getcode.SessionState -import com.getcode.Session +import com.getcode.SessionController import com.getcode.LocalBiometricsState import com.getcode.PresentationStyle import com.getcode.R @@ -55,8 +54,6 @@ import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.AccountModal import com.getcode.navigation.screens.BalanceModal -import com.getcode.navigation.screens.BuyMoreKinModal -import com.getcode.navigation.screens.BuySellScreen import com.getcode.navigation.screens.ChatListModal import com.getcode.navigation.screens.ConnectAccount import com.getcode.navigation.screens.EnterTipModal @@ -68,7 +65,6 @@ import com.getcode.ui.components.PermissionResult import com.getcode.ui.components.getPermissionLauncher import com.getcode.ui.components.rememberPermissionChecker import com.getcode.ui.utils.AnimationUtils -import com.getcode.ui.utils.ModalAnimationSpeed import com.getcode.ui.utils.measured import com.getcode.util.launchAppSettings import com.getcode.view.login.notificationPermissionCheck @@ -76,11 +72,8 @@ import com.getcode.view.main.bill.BillManagementOptions import com.getcode.view.main.scanner.views.CameraDisabledView import com.getcode.view.main.scanner.camera.CodeScanner import com.getcode.view.main.bill.HomeBill -import com.getcode.view.main.scanner.modals.LoginConfirmation -import com.getcode.view.main.scanner.modals.PaymentConfirmation import com.getcode.view.main.scanner.views.CameraPermissionsMissingView -import com.getcode.view.main.scanner.modals.ReceivedKinConfirmation -import com.getcode.view.main.scanner.modals.TipConfirmation +import com.getcode.ui.modals.ReceivedKinConfirmation import com.getcode.view.main.scanner.views.HomeRestricted import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn @@ -104,12 +97,12 @@ enum class UiElement { @Composable fun ScanScreen( - session: Session, + session: SessionController, cashLink: String? = null, request: DeepLinkRequest? = null, ) { val navigator = LocalCodeNavigator.current - val dataState by session.uiFlow.collectAsState() + val dataState by session.state.collectAsState() when (val restrictionType = dataState.restrictionType) { RestrictionType.ACCESS_EXPIRED, @@ -145,6 +138,8 @@ fun ScanScreen( SessionEvent.RequestNotificationPermissions -> { notificationPermissionChecker(true) } + + is SessionEvent.OnChatPaidForSuccessfully -> Unit } } .launchIn(this) @@ -155,7 +150,7 @@ fun ScanScreen( @Composable private fun ScannerContent( - session: Session, + session: SessionController, dataState: SessionState, cashLink: String?, request: DeepLinkRequest?, @@ -330,7 +325,7 @@ private fun BillContainer( isCameraStarted: Boolean, isPaused: Boolean, dataState: SessionState, - session: Session, + session: SessionController, scannerView: @Composable () -> Unit, onStartCamera: () -> Unit, onAction: (UiElement) -> Unit, @@ -521,89 +516,5 @@ private fun BillContainer( } } } - - // Payment Confirmation container - AnimatedContent( - modifier = Modifier.align(BottomCenter), - targetState = updatedState.billState.paymentConfirmation?.payload, // payload is constant across state changes - transitionSpec = AnimationUtils.modalAnimationSpec(), - label = "payment confirmation", - ) { - if (it != null) { - Box( - contentAlignment = BottomCenter - ) { - - PaymentConfirmation( - confirmation = updatedState.billState.paymentConfirmation, - balance = updatedState.balance, - onAddKin = { - session.rejectPayment() - if (updatedState.buyModule.enabled) { - if (updatedState.buyModule.available) { - navigator.show(BuyMoreKinModal(showClose = true)) - } else { - TopBarManager.showMessage( - TopBarManager.TopBarMessage( - title = context.getString(R.string.error_title_buyModuleUnavailable), - message = context.getString(R.string.error_description_buyModuleUnavailable), - type = TopBarManager.TopBarMessageType.ERROR - ) - ) - } - } else { - navigator.show(BuySellScreen) - } - }, - onSend = { session.completePayment() }, - onCancel = { - session.rejectPayment() - } - ) - } - } - } - - // Login Confirmation container - AnimatedContent( - modifier = Modifier.align(BottomCenter), - targetState = updatedState.billState.loginConfirmation?.payload, // payload is constant across state changes - transitionSpec = AnimationUtils.modalAnimationSpec(), - label = "login confirmation", - ) { - if (it != null) { - Box( - contentAlignment = BottomCenter - ) { - LoginConfirmation( - confirmation = updatedState.billState.loginConfirmation, - onSend = { session.completeLogin() }, - onCancel = { - session.rejectLogin() - } - ) - } - } - } - - // Tip Confirmation container - AnimatedContent( - modifier = Modifier.align(BottomCenter), - targetState = updatedState.billState.tipConfirmation?.payload, // payload is constant across state changes - transitionSpec = AnimationUtils.modalAnimationSpec(speed = ModalAnimationSpeed.Fast), - label = "tip confirmation", - ) { - if (it != null) { - Box( - contentAlignment = BottomCenter - ) { - TipConfirmation( - confirmation = updatedState.billState.tipConfirmation, - onSend = { session.completeTipPayment() }, - onCancel = { session.cancelTip() } - ) - } - } - } } } diff --git a/scripts/fetch-protos.sh b/scripts/fetch-protos.sh index 43aa24b92..8c9b063ec 100755 --- a/scripts/fetch-protos.sh +++ b/scripts/fetch-protos.sh @@ -1,11 +1,33 @@ #!/bin/bash root=$(pwd) -REPO_URL="https://github.com/code-payments/code-protobuf-api" -COMMIT_SHA=$1 +REPO_URL="https://github.com/code-payments/code-protobuf-api" # Default repo URL +COMMIT_SHA="" +RUN_STRIP_PROTO_VALIDATION=false # Default to not running the script TEMP_DIR=$(mktemp -d) DEST_DIR="service/protos/src/main/proto" +# Parse options +while getopts ":r:x" opt; do + case ${opt} in + r ) + REPO_URL=$OPTARG + ;; + x ) + RUN_STRIP_PROTO_VALIDATION=true + ;; + \? ) + echo "Invalid option: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +shift $((OPTIND -1)) + +# Get the commit SHA if provided +COMMIT_SHA=$1 + # Clone the repository git clone "$REPO_URL" "$TEMP_DIR" @@ -20,7 +42,7 @@ else fi # Create the destination directory if it doesn't exist -mkdir -p "../../$DEST_DIR" +mkdir -p "${root}/$DEST_DIR" # Copy proto files if [ -d "proto" ]; then @@ -35,4 +57,21 @@ fi cd ../.. rm -rf "$TEMP_DIR" -sh "${root}"/scripts/strip-proto-validation.sh +# Conditionally run the strip proto validation script +if [ "$RUN_STRIP_PROTO_VALIDATION" = true ]; then + SCRIPT_PATH="${root}/scripts/strip-proto-validation.sh" + + # Ensure the script exists and is executable + if [ -f "$SCRIPT_PATH" ]; then + if [ -x "$SCRIPT_PATH" ]; then + echo "Running strip-proto-validation.sh" + "$SCRIPT_PATH" + else + echo "Error: strip-proto-validation.sh is not executable. Run 'chmod +x $SCRIPT_PATH' to fix this." + exit 1 + fi + else + echo "Error: strip-proto-validation.sh not found at $SCRIPT_PATH" + exit 1 + fi +fi diff --git a/service/protos/src/main/proto/chat/v2/chat_service.proto b/service/protos/src/main/proto/chat/v2/chat_service.proto index 1479fba5c..47003e0d1 100644 --- a/service/protos/src/main/proto/chat/v2/chat_service.proto +++ b/service/protos/src/main/proto/chat/v2/chat_service.proto @@ -83,7 +83,7 @@ message GetChatsResponse { repeated ChatMetadata chats = 2 ; } message GetMessagesRequest { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; ChatMemberId member_id = 2; common.v1.SolanaAccountId owner = 3; common.v1.Signature signature = 4; @@ -106,7 +106,7 @@ message GetMessagesResponse { repeated ChatMessage messages = 2 ; } message OpenChatEventStream { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; ChatMemberId member_id = 2 ; common.v1.SolanaAccountId owner = 3; common.v1.Signature signature = 4; @@ -191,7 +191,7 @@ message StartChatResponse { ChatMetadata chat = 2; } message SendMessageRequest { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; ChatMemberId member_id = 2 ; // Allowed content types that can be sent by client: // - TextContent @@ -214,7 +214,7 @@ message SendMessageResponse { ChatMessage message = 2; } message AdvancePointerRequest { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; Pointer pointer = 2; common.v1.SolanaAccountId owner = 3; common.v1.Signature signature = 4; @@ -230,7 +230,7 @@ message AdvancePointerResponse { } } message RevealIdentityRequest { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; ChatMemberId member_id = 2; ChatMemberIdentity identity = 3; common.v1.SolanaAccountId owner = 4; @@ -248,7 +248,7 @@ message RevealIdentityResponse { ChatMessage message = 2; } message SetMuteStateRequest { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; ChatMemberId member_id = 2 ; bool is_muted = 3; common.v1.SolanaAccountId owner = 4; @@ -264,7 +264,7 @@ message SetMuteStateResponse { } } message SetSubscriptionStateRequest { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; ChatMemberId member_id = 2 ; bool is_subscribed = 3; common.v1.SolanaAccountId owner = 4; @@ -280,7 +280,7 @@ message SetSubscriptionStateResponse { } } message NotifyIsTypingRequest { - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; ChatMemberId member_id = 2 ; bool is_typing = 3; common.v1.SolanaAccountId owner = 4; @@ -294,11 +294,6 @@ message NotifyIsTypingResponse { CHAT_NOT_FOUND = 2; } } -message ChatId { - // Sufficient space is left for a consistent hash value, though other types - // of values may be used. - bytes value = 1 ; -} message ChatMessageId { // Guaranteed to be a time-based UUID. This should be used to construct a // consistently ordered message history based on time using a simple byte @@ -330,7 +325,7 @@ enum PointerType { // todo: Support is_verified in a clean way message ChatMetadata { // Globally unique ID for this chat - ChatId chat_id = 1; + common.v1.ChatId chat_id = 1; // The type of chat ChatType type = 2 ; // The chat title, which will be localized by server when applicable diff --git a/service/protos/src/main/proto/common/v1/model.proto b/service/protos/src/main/proto/common/v1/model.proto index 6d681311e..177fb318e 100644 --- a/service/protos/src/main/proto/common/v1/model.proto +++ b/service/protos/src/main/proto/common/v1/model.proto @@ -60,6 +60,11 @@ message IntentId { message UserId { bytes value = 1 ; } +message ChatId { + // Sufficient space is left for a consistent hash value, though other types + // of values may be used. + bytes value = 1 ; +} // DataContainerId is a globally unique identifier for a container where a user // can store a copy of their data message DataContainerId { diff --git a/service/protos/src/main/proto/transaction/v2/transaction_service.proto b/service/protos/src/main/proto/transaction/v2/transaction_service.proto index af20a6772..285a54819 100644 --- a/service/protos/src/main/proto/transaction/v2/transaction_service.proto +++ b/service/protos/src/main/proto/transaction/v2/transaction_service.proto @@ -556,10 +556,10 @@ message SendPrivatePaymentMetadata { bool is_tip = 5; // If is_tip is true, the user being tipped TippedUser tipped_user = 6; - // Is the payment for a friendship? - bool is_friendship = 7; - // If is_friendship is true, the user being friended - FriendedUser friended_user = 8; + // Is the payment for a chat? + bool is_chat = 7; + // If is_chat is true, the chat being paid for. + common.v1.ChatId chat_id = 8; } // Send a payment to a destination account publicly. // diff --git a/service/protos/src/main/proto/user/v1/identity_service.proto b/service/protos/src/main/proto/user/v1/identity_service.proto index c3eaa5061..0ba2b2514 100644 --- a/service/protos/src/main/proto/user/v1/identity_service.proto +++ b/service/protos/src/main/proto/user/v1/identity_service.proto @@ -307,4 +307,10 @@ message TwitterUser { // =========================================================== // Indicates the user is a friend of the caller. bool is_friend = 10; + // The ChatId used to communicate with this friend. + // + // This will always be set for authenticated users. + // If is_friend=false, this ChatId should be used when crafting + // the intent. + common.v1.ChatId friend_chat_id = 11; }