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
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -768,4 +768,8 @@
<string name="subtitle_verb_youSent">You sent</string>
<string name="subtitle_verb_youReceived">You received</string>

<string name="label_chatReceipt_delivered">Delivered</string>
<string name="label_chatReceipt_read">Read %1$s</string>
<string name="label_chatReceipt_yesterday">Yesterday</string>

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.flipcash.services.models.buildDmPaymentMetadata
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.DeliveryStatus
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.user.UserManager
import com.flipcash.shared.amountentry.AmountEntryDelegate
Expand Down Expand Up @@ -162,16 +163,15 @@ internal class ChatViewModel @Inject constructor(
private val separatorConfig = SeparatorConfig.TimeGap()

@OptIn(ExperimentalCoroutinesApi::class)
private val messageStream = stateFlow.mapNotNull { it.chatId }.distinctUntilChanged()
private val messageStream = stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatCoordinator.observeMessagesPaged(it) }

@OptIn(ExperimentalCoroutinesApi::class)
val otherReadPointer = stateFlow
.map { it.chatId }
.filterNotNull()
val otherReadPointer = stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatCoordinator.observeOtherReadPointer(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)

@OptIn(ExperimentalCoroutinesApi::class)
val messages: Flow<PagingData<ChatListItem>> = messageStream
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import dev.chrisbanes.haze.rememberHazeState
internal fun MessengerScreen(viewModel: ChatViewModel) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val messages = viewModel.messages.collectAsLazyPagingItems()
val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle()
val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null)
val navigator = LocalCodeNavigator.current

val hazeState = rememberHazeState()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
Expand All @@ -27,10 +22,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.flipcash.services.models.chat.MessageContent
import androidx.compose.ui.unit.dp
import com.flipcash.services.models.chat.MessagePointer
import com.getcode.theme.CodeTheme
import com.getcode.ui.utils.sheetResignmentBehavior
import com.getcode.util.toLocalDate
Expand Down Expand Up @@ -94,7 +90,7 @@ internal fun MessageList(
contentPadding: PaddingValues,
messages: LazyPagingItems<ChatListItem>,
separatorConfig: SeparatorConfig,
otherReadPointer: Long = 0L,
otherReadPointer: MessagePointer? = null,
onAdvanceReadPointer: ((Long) -> Unit)? = null,
) {
val listAlpha by animateFloatAsState(
Expand Down Expand Up @@ -149,7 +145,7 @@ internal fun MessageList(
val showReceipt =
shouldShowReceiptLabel(index, item, messages, otherReadPointer)
if (showReceipt && effectiveStatus != null) {
ReceiptLabel(effectiveStatus)
ReceiptLabel(effectiveStatus, otherReadPointer)
}
}
}
Expand Down Expand Up @@ -241,10 +237,11 @@ private fun HandleMessageReads(

private fun effectiveReceiptStatus(
bubble: ChatListItem.ContentBubble,
otherReadPointer: Long,
otherReadPointer: MessagePointer?,
): ReceiptStatus? {
val base = bubble.receiptStatus ?: return null
if (base == ReceiptStatus.SENT && bubble.messageId in 1..otherReadPointer) {
val pointerValue = otherReadPointer?.value ?: 0L
if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) {
return ReceiptStatus.READ
}
return base
Expand All @@ -262,7 +259,7 @@ private fun shouldShowReceiptLabel(
index: Int,
item: ChatListItem.ContentBubble,
messages: LazyPagingItems<ChatListItem>,
otherReadPointer: Long,
otherReadPointer: MessagePointer?,
): Boolean {
if (!item.isFromSelf) return false
val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false
Expand Down Expand Up @@ -302,23 +299,3 @@ private fun shouldShowReceiptLabel(
return true
}

@Composable
private fun ReceiptLabel(status: ReceiptStatus, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = status,
modifier = modifier.padding(top = CodeTheme.dimens.grid.x1),
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "receiptStatus",
) { animatedStatus ->
val text = when (animatedStatus) {
ReceiptStatus.SENT -> "Delivered"
ReceiptStatus.READ -> "Read"
else -> return@AnimatedContent
}
Text(
text = text,
style = CodeTheme.typography.caption,
color = CodeTheme.colors.textSecondary,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.flipcash.app.messenger.internal.screens.components

import android.text.format.DateFormat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.PointerType
import com.getcode.theme.CodeTheme
import com.getcode.util.formatLocalized
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant

@Composable
internal fun ReceiptLabel(
status: ReceiptStatus,
readPointer: MessagePointer?,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = status,
modifier = modifier.padding(top = CodeTheme.dimens.grid.x1),
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "receiptStatus",
) { animatedStatus ->
val text = when (animatedStatus) {
ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered)
ReceiptStatus.READ -> {
val readAtFormatted = readPointer?.timestamp?.let { formatReadTimestamp(it) } ?: ""
stringResource(R.string.label_chatReceipt_read, readAtFormatted)
}
else -> return@AnimatedContent
}
Text(
text = text,
style = CodeTheme.typography.caption,
color = CodeTheme.colors.textSecondary,
)
}
}

@Composable
internal fun formatReadTimestamp(instant: Instant): String {
val context = LocalContext.current
val is24Hour = DateFormat.is24HourFormat(context)
val now = Clock.System.now()
val tz = TimeZone.currentSystemDefault()
val todayDate = now.toLocalDateTime(tz).date
val readDate = instant.toLocalDateTime(tz).date

val dayDiff = todayDate.toEpochDays() - readDate.toEpochDays()

return when {
// Today -> time only (e.g. "11:15 AM")
dayDiff == 0L -> instant.formatLocalized(
"h:mm a", is24Hour = is24Hour, if24Hour = "H:mm"
)
// Yesterday
dayDiff == 1L -> stringResource(R.string.label_chatReceipt_yesterday)
// 2-6 days ago -> weekday name (e.g. "Monday")
dayDiff in 2L..6L -> instant.formatLocalized("EEEE")
// Same year -> month + day (e.g. "Jun 8")
readDate.year == todayDate.year -> instant.formatLocalized("MMM d")
// Earlier year -> month + day + year (e.g. "Jun 8, 2025")
else -> instant.formatLocalized("MMM d, yyyy")
}
}

// region Previews

private fun previewPointer(timestamp: Instant) = MessagePointer(
type = PointerType.READ,
userId = listOf(1.toByte()),
value = 1L,
timestamp = timestamp,
)

@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun Preview_AllStates() {
val now = Clock.System.now()
Column {
ReceiptLabel(status = ReceiptStatus.SENT, readPointer = null)
ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now))
ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(1.days)))
ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(3.days)))
ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(30.days)))
ReceiptLabel(status = ReceiptStatus.READ, readPointer = previewPointer(now.minus(400.days)))
}
}

// endregion
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,13 @@ class ChatCoordinator @Inject constructor(
return _state.map { it.typingIndicators[chatId] ?: emptySet() }
}

fun observeOtherReadPointer(chatId: ChatId): Flow<Long> {
fun observeOtherReadPointer(chatId: ChatId): Flow<MessagePointer?> {
val selfId = userManager.accountId
return memberDataSource.observeMembers(chatId)
.map { members ->
members.firstOrNull { it.userId != selfId }
?.pointers
?.firstOrNull { it.type == PointerType.READ }
?.value ?: 0L
}
.distinctUntilChanged()
}
Expand Down
Loading