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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,10 @@

<string name="label_chatReceipt_delivered">Delivered</string>
<string name="label_chatReceipt_read">Read</string>
<string name="label_chatReceipt_notSent">Not Sent</string>
<string name="label_chatReceipt_yesterday">Yesterday</string>
<string name="title_messageNotSent">Message Not Sent</string>
<string name="description_messageNotSent">Your message could not be delivered. Would you like to try again?</string>
<string name="label_chatSeparator_today">Today</string>

<string name="title_sendFeatureIntro">Send Money To Your Friends</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.math.min
Expand Down Expand Up @@ -135,6 +137,7 @@ internal class ChatViewModel @Inject constructor(
data object ResolveFailed : Event

data object SendMessage : Event
data class RetryMessage(val pendingId: String?, val content: MessageContent) : Event

data class NavigateToAmountEntry(val contact: DeviceContact) : Event
data object PresentDepositOptions : Event
Expand Down Expand Up @@ -269,7 +272,7 @@ internal class ChatViewModel @Inject constructor(
if (chatId != null) {
dispatchEvent(Event.ChatFound(chatId))
chatCoordinator.setActiveChatId(chatId)
chatCoordinator.loadMessages(chatId)
viewModelScope.launch { chatCoordinator.loadMessages(chatId) }
chatCoordinator.dismissNotifications(chatId)
}

Expand All @@ -293,26 +296,24 @@ internal class ChatViewModel @Inject constructor(
// Resolve owner authority for sending cash
eventFlow
.filterIsInstance<Event.OnContactFound>()
.map { it.contact }
.map {
contactCoordinator.resolve(it.e164)
}.onResult(
onSuccess = {
dispatchEvent(Event.ResolveCompleted(it))
},
onError = {
dispatchEvent(Event.ResolveFailed)
.onEach { event ->
viewModelScope.launch {
contactCoordinator.resolve(event.contact.e164)
.onSuccess { dispatchEvent(Event.ResolveCompleted(it)) }
.onFailure { dispatchEvent(Event.ResolveFailed) }
}
).launchIn(viewModelScope)
}.launchIn(viewModelScope)

// Re-resolve the contact from the device (e.g. after adding via system contacts)
eventFlow
.filterIsInstance<Event.RefreshContact>()
.mapNotNull { stateFlow.value.chattingWith?.e164 }
.onEach { e164 ->
val refreshed = contactCoordinator.refreshContact(e164)
if (refreshed != null) {
dispatchEvent(Event.OnContactFound(refreshed))
viewModelScope.launch {
val refreshed = contactCoordinator.refreshContact(e164)
if (refreshed != null) {
dispatchEvent(Event.OnContactFound(refreshed))
}
}
}
.launchIn(viewModelScope)
Expand Down Expand Up @@ -341,7 +342,7 @@ internal class ChatViewModel @Inject constructor(
.filterIsInstance<Event.AdvanceReadPointer>()
.onEach { event ->
val chatId = stateFlow.value.chatId ?: return@onEach
chatCoordinator.advanceReadPointer(chatId, event.messageId)
viewModelScope.launch { chatCoordinator.advanceReadPointer(chatId, event.messageId) }
}
.launchIn(viewModelScope)
}
Expand Down Expand Up @@ -418,32 +419,31 @@ internal class ChatViewModel @Inject constructor(
}
.launchIn(viewModelScope)

// Notify server of typing state changes
// Notify server of typing state changes (fire-and-forget to avoid
// blocking SharedFlow emission when the gRPC call hangs offline)
eventFlow.filterIsInstance<Event.OnSelfTypingStarted>()
.mapNotNull { stateFlow.value.chatId }
.onEach { chatCoordinator.notifyTyping(it, TypingState.STARTED_TYPING) }
.onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STARTED_TYPING) } }
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.OnSelfTypingStill>()
.mapNotNull { stateFlow.value.chatId }
.onEach { chatCoordinator.notifyTyping(it, TypingState.STILL_TYPING) }
.onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STILL_TYPING) } }
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.OnSelfTypingStopped>()
.mapNotNull { stateFlow.value.chatId }
.onEach { chatCoordinator.notifyTyping(it, TypingState.STOPPED_TYPING) }
.onEach { viewModelScope.launch { chatCoordinator.notifyTyping(it, TypingState.STOPPED_TYPING) } }
.launchIn(viewModelScope)

// Observe typing indicators once chatId is known
stateFlow.map { it.chatId }
.filterNotNull()
stateFlow.mapNotNull { it.chatId }
.flatMapLatest { chatId -> chatCoordinator.observeTypingIndicators(chatId) }
.onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) }
.launchIn(viewModelScope)

// Enable typing notifications once a payment has been exchanged
stateFlow.map { it.chatId }
.filterNotNull()
stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatId ->
chatCoordinator.observeMessages(chatId)
Expand All @@ -459,20 +459,45 @@ internal class ChatViewModel @Inject constructor(
private fun initSendHandlers() {
// Send text message
eventFlow.filterIsInstance<Event.SendMessage>()
.map { stateFlow.value.chatInputState }
.mapNotNull { textInput ->
val textToSend = textInput.text.toString()
val chatId = stateFlow.value.chatId ?: return@mapNotNull null
.onEach {
val textToSend = stateFlow.value.chatInputState.text.toString()
val chatId = stateFlow.value.chatId ?: return@onEach
if (textToSend.isBlank()) return@onEach

stateFlow.value.chatInputState.clearText()
chatCoordinator.sendMessage(chatId, textToSend)
}.onResult(
onSuccess = {
trace("message sent successfully")
},
onError = {
trace("message failed to send - ${it.localizedMessage}")

viewModelScope.launch {
chatCoordinator.sendMessage(chatId, textToSend)
.onSuccess { trace("message sent successfully") }
.onFailure { trace("message failed to send - ${it.localizedMessage}") }
}
)
}
.flowOn(Dispatchers.Main.immediate)
.launchIn(viewModelScope)

// Retry a failed message
eventFlow.filterIsInstance<Event.RetryMessage>()
.onEach { (pendingClientIdHex, content) ->
val chatId = stateFlow.value.chatId ?: return@onEach
val pendingId = pendingClientIdHex ?: return@onEach

BottomBarManager.showInfo(
title = resources.getString(R.string.title_messageNotSent),
message = resources.getString(R.string.description_messageNotSent),
actions = listOf(
BottomBarAction(
text = resources.getString(R.string.action_retry),
) {
viewModelScope.launch {
chatCoordinator.retryMessage(chatId, pendingId, listOf(content))
.onSuccess { trace("retry message sent successfully") }
.onFailure { trace("retry message failed - ${it.localizedMessage}") }
}
},
),
showCancel = true,
)
}
.launchIn(viewModelScope)

// confirmation of amount and checks
Expand Down Expand Up @@ -687,6 +712,7 @@ internal class ChatViewModel @Inject constructor(
state.copy(resolveState = ResolveState.Failed)
}
is Event.SendMessage -> { state -> state }
is Event.RetryMessage -> { state -> state }
is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
is Event.PresentDepositOptions -> { state -> state }
is Event.OpenScreen -> { state -> state }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
val navigator = LocalCodeNavigator.current

val hazeState = rememberHazeState()
val keyboard = rememberKeyboardController()

ChatInputScaffold(
topBar = { ChatTopBar(navigator, state.chattingWith) },
Expand Down Expand Up @@ -101,6 +102,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
onAdvanceReadPointer = { messageId ->
viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId))
},
onRetryMessage = { bubble ->
keyboard.hideIfVisible {
viewModel.dispatchEvent(
ChatViewModel.Event.RetryMessage(bubble.pendingClientIdHex, bubble.content)
)
}
},
onRefreshContact = {
viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ internal fun MessageList(
separatorConfig: SeparatorConfig,
otherReadPointer: MessagePointer? = null,
onAdvanceReadPointer: ((Long) -> Unit)? = null,
onRetryMessage: ((ChatListItem.ContentBubble) -> Unit)? = null,
onRefreshContact: () -> Unit = {},
) {
val keyboard = rememberKeyboardController()
Expand Down Expand Up @@ -215,6 +216,9 @@ internal fun MessageList(
status = effectiveStatus,
readPointer = otherReadPointer,
animateEntrance = wasSending,
onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) {
{ onRetryMessage?.invoke(item) }
} else null,
)
}
}
Expand Down Expand Up @@ -401,6 +405,7 @@ private fun shouldShowReceiptLabel(
): Boolean {
if (!item.isFromSelf) return false
val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false
if (status == ReceiptStatus.FAILED) return true
if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false

// index - 1 is the item below (newer) in reverseLayout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -53,6 +54,7 @@ internal fun ReceiptLabel(
readPointer: MessagePointer?,
modifier: Modifier = Modifier,
animateEntrance: Boolean = false,
onRetryFailed: (() -> Unit)? = null,
) {
// iOS: "Delivered" hides instantly on send, then appears after 700ms with
// scale(0.95)+opacity spring (duration: 0.4, bounce: 0.12).
Expand Down Expand Up @@ -103,21 +105,32 @@ internal fun ReceiptLabel(
val text = when (animatedStatus) {
ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered)
ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read)
ReceiptStatus.FAILED -> stringResource(R.string.label_chatReceipt_notSent)
else -> return@AnimatedContent
}

val readAtFormatted =
readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: ""

Row(
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
modifier = if (animatedStatus == ReceiptStatus.FAILED && onRetryFailed != null) {
Modifier.clickable(onClick = onRetryFailed)
} else {
Modifier
},
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
) {
Text(
modifier = Modifier.alignByBaseline(),
text = text,
style = CodeTheme.typography.caption.copy(
fontWeight = FontWeight.Bold,
),
color = CodeTheme.colors.textSecondary,
color = if (animatedStatus == ReceiptStatus.FAILED) {
CodeTheme.colors.error
} else {
CodeTheme.colors.textSecondary
},
)

if (animatedStatus == ReceiptStatus.READ && readAtFormatted.isNotEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMember
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.ReactionSummary
import com.flipcash.services.models.chat.TypingState
Expand Down Expand Up @@ -87,6 +88,9 @@ interface MessagingOperations {
/** Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. */
suspend fun sendMessage(chatId: ChatId, content: String): Result<ChatMessage>

/** Retries a failed pending message: resets to SENDING and re-sends to the server. */
suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List<MessageContent>): Result<ChatMessage>

/** Advances the local and remote read pointer for [chatId] to [messageId]. */
suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result<Unit>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ class MessagingDelegate @Inject constructor(
}

override suspend fun sendMessage(chatId: ChatId, content: String): Result<ChatMessage> {
if (content.isBlank()) {
return Result.failure(IllegalArgumentException("Cannot send a blank message"))
}

val senderId = userManager.accountId
?: return Result.failure(IllegalStateException("Cannot send message without an account"))

Expand All @@ -164,6 +168,22 @@ class MessagingDelegate @Inject constructor(
}
}

override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List<MessageContent>): Result<ChatMessage> {
val clientMessageId = messageDataSource.retryPending(chatId, pendingClientIdHex)

return messagingController.sendMessage(chatId, content, clientMessageId)
.onSuccess { serverMessage ->
messageDataSource.confirmPending(chatId, clientMessageId, serverMessage)
advanceReadPointer(chatId, serverMessage.messageId)

metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId)
metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds())
}
.onFailure {
messageDataSource.failPending(chatId, clientMessageId)
}
}

override suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result<Unit> {
val selfId = userManager.accountId ?: return Result.failure(
IllegalStateException("No account")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ class ChatMessageDataSource @Inject constructor(
)
}

suspend fun retryPending(chatId: ChatId, pendingClientIdHex: String): ClientMessageId {
val clientMessageId = mapper.clientMessageIdFromHex(pendingClientIdHex)
db?.chatMessageDao()?.updatePendingStatus(
mapper.chatIdHex(chatId),
pendingClientIdHex,
MessageStatus.SENDING,
)
return clientMessageId
}

fun toChatMessage(entity: ChatMessageEntity): ChatMessage {
val message = mapper.toMessage(entity)
val selfId = userManager.accountId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ class ChatEntityMapper @Inject constructor() {
fun clientMessageIdHex(clientMessageId: ClientMessageId): String =
clientMessageId.bytes.toList().hexEncodedString()

fun clientMessageIdFromHex(hex: String): ClientMessageId =
ClientMessageId(hex.hexToByteArray())

fun pointerToJson(pointer: MessagePointer): String {
return kotlinx.serialization.json.Json.encodeToString(
listOf(pointer.toSerialized())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import dev.bmcreations.protovalidate.orThrow
import io.grpc.ManagedChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -114,7 +115,8 @@ internal class ChatMessagingApi @Inject constructor(
request.validate().orThrow()

return withContext(Dispatchers.IO) {
api.sendMessage(request)
api.withDeadlineAfter(30, TimeUnit.SECONDS)
.sendMessage(request)
}
}

Expand Down
Loading