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
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,7 @@
<string name="hint_messageUndecryptable_askToResend">Ask %1$s to send it again</string>
<string name="hint_messageUndecryptable_tryAgain">Try sending it again</string>

<string name="label_e2eeMarker">Encrypted</string>
<string name="label_e2eeFooter_dm">Messages are end-to-end encrypted</string>
<string name="label_e2eeFooter_group">Group chats aren\'t end-to-end encrypted</string>
<string name="action_learnMore">Learn More</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ import com.flipcash.shared.chat.reactions.ReactionStripComposer
import com.flipcash.shared.chat.reactions.SelfReaction
import com.flipcash.shared.chat.readOnly
import com.flipcash.shared.chat.resolveCapabilities
import com.flipcash.shared.chat.ui.UndecryptableHint
import com.flipcash.shared.chat.ui.undecryptableHint
import com.flipcash.shared.chat.ui.detectMentions
import com.flipcash.shared.chat.ui.detectUrls
import com.flipcash.shared.chat.ui.linkableText
Expand Down Expand Up @@ -722,6 +724,13 @@ internal class ChatViewModel @Inject constructor(
// cache: an edit or delete awaiting the server re-runs the mapping without re-fetching.
.cachedIn(viewModelScope)

/** Where the Encrypted marker goes: read from the transcript, never from `use_e2ee`. */
@OptIn(ExperimentalCoroutinesApi::class)
private val oldestEncryptedId = stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.flatMapLatest { chatCoordinator.observeOldestEncryptedMessageId(it) }
.distinctUntilChanged()

/** Edits and deletes the server has not answered yet, composed over the stored transcript. */
@OptIn(ExperimentalCoroutinesApi::class)
private val pendingMutations = stateFlow.mapNotNull { it.chatId }
Expand Down Expand Up @@ -952,6 +961,14 @@ internal class ChatViewModel @Inject constructor(
reactionPills = storedReactions.pills,
selfReactions = storedReactions.selfReactions,
canReact = canReact(message),
undecryptableHint = undecryptableHint(
encryption = message.encryption,
isFromSelf = message.isFromSelf,
// Only a DM is encrypted, so the sender of an incoming one is the
// participant.
senderName = stateFlow.value.participant?.name
?: resources.getString(R.string.title_unnamedUser),
) ?: UndecryptableHint.UpdateApp,
)
// A carded link takes a row of its own, with the prose either side of it
// on rows above and below. Reversed because the list is: this page runs
Expand Down Expand Up @@ -998,7 +1015,10 @@ internal class ChatViewModel @Inject constructor(
.filterNot { it is UnreadBoundary.Resolving }
.distinctUntilChanged(),
stateFlow.map { it.separatorConfig }.distinctUntilChanged(),
) { paging, boundary, config -> paging.withSeparators(boundary, config) }
oldestEncryptedId,
) { paging, boundary, config, oldestEncrypted ->
paging.withSeparators(boundary, config, oldestEncrypted)
}

/**
* The citation shown for [this] message.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,35 @@ internal fun unreadDividerBetween(
return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId
}

/** The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. */
/**
* The one item that goes in the gap between [newer] and [older] in the newest-first list, if any.
*
* [oldestEncryptedId] is the oldest stored message that arrived end-to-end encrypted. The gap below
* its last row takes the Encrypted marker, carrying whatever separator the gap would have had.
*/
internal fun separatorBetween(
newer: ChatListItem.ContentBubble?,
older: ChatListItem.ContentBubble?,
boundary: UnreadBoundary,
config: SeparatorConfig,
oldestEncryptedId: Long? = null,
): ChatListItem? {
newer ?: return null
val separator = plainSeparatorBetween(newer, older, boundary, config)
val marksEncryption = newer.messageId == oldestEncryptedId && older?.messageId != newer.messageId
if (!marksEncryption) return separator
// At the head the list draws the oldest date as a trailing header unless the oldest item
// already carries one, and the marker is now the oldest item.
val above = separator ?: if (older == null) ChatListItem.DateSeparator(newer.timestamp) else null
return ChatListItem.EncryptedMarker(above)
}

private fun plainSeparatorBetween(
newer: ChatListItem.ContentBubble,
older: ChatListItem.ContentBubble?,
boundary: UnreadBoundary,
config: SeparatorConfig,
): ChatListItem? {
if (boundary is UnreadBoundary.At && unreadDividerBetween(newer, older, boundary)) {
// The oldest stored message has no separator of its own; the list draws its date as a
// trailing header. The divider sits there instead, so it carries that date.
Expand All @@ -58,7 +79,8 @@ internal fun separatorBetween(
internal fun PagingData<ChatListItem.ContentBubble>.withSeparators(
boundary: UnreadBoundary,
config: SeparatorConfig,
oldestEncryptedId: Long? = null,
): PagingData<ChatListItem> =
insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? ->
separatorBetween(newer, older, boundary, config)
separatorBetween(newer, older, boundary, config, oldestEncryptedId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}

ChatAction.OpenEncryptionInfo -> keyboard.hideIfVisible {
navigator.push(AppRoute.Messaging.E2eeDmInfo)
}

ChatAction.AddCash -> {
// The gate is the only place in the transcript that offers it.
viewModel.dispatchEvent(ChatViewModel.Event.GateFundingTapped(GroupGateFunding.ADD_CASH))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.features.messenger.R
import com.flipcash.shared.chat.models.ChatListItem
import com.getcode.theme.CodeTheme
import kotlin.time.Clock

/**
* "🔒 Encrypted ›" (node 10416:1404), above the first message that arrived end-to-end encrypted.
* [above] is the date separator or unread divider that shares its gap, drawn first so the reader
* sees the day, then the divider, then the marker and the message.
*/
@Composable
internal fun EncryptedMarkerRow(
above: ChatListItem?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
when (above) {
is ChatListItem.DateSeparator -> DateSeparatorRow(above.timestamp)
is ChatListItem.UnreadDivider -> UnreadDividerRow(count = above.count, date = above.date)
else -> Unit
}
Row(
modifier = Modifier
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(12.dp),
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = CodeTheme.colors.textSecondary,
)
Text(
text = stringResource(R.string.label_e2eeMarker),
style = CodeTheme.typography.textSmall.copy(
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
),
color = CodeTheme.colors.textSecondary,
)
Icon(
modifier = Modifier.size(14.dp),
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = CodeTheme.colors.textSecondary,
)
}
}
}

@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun Preview_EncryptedMarker() {
Column {
EncryptedMarkerRow(above = null, onClick = {})
EncryptedMarkerRow(above = ChatListItem.DateSeparator(Clock.System.now()), onClick = {})
EncryptedMarkerRow(above = ChatListItem.UnreadDivider(count = 3), onClick = {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ internal fun MessageList(
is ChatListItem.ContentBubble -> oldest.timestamp
is ChatListItem.DateSeparator -> null // already a separator
is ChatListItem.UnreadDivider -> null // carries the oldest message's date
is ChatListItem.EncryptedMarker -> null // carries it too, as `above`
null -> null
}
if (oldestTimestamp != null) {
Expand Down Expand Up @@ -738,7 +739,7 @@ private suspend fun LazyPagingItems<ChatListItem>.walkUntil(budget: Int, find: (

/** The presented index of the unread divider, or `null` while it is still unloaded. */
private fun unreadDividerIndex(messages: LazyPagingItems<ChatListItem>): Int? =
(0 until messages.itemCount).firstOrNull { messages.peek(it) is ChatListItem.UnreadDivider }
(0 until messages.itemCount).firstOrNull { messages.peek(it)?.holdsUnreadDivider == true }

/**
* Whether the unread divider is laid out whole below the top bar. A divider under the bar's fade
Expand All @@ -748,7 +749,7 @@ private fun LazyListLayoutInfo.showsUnreadDivider(messages: LazyPagingItems<Chat
val band = centeringBand()
return visibleItemsInfo.any { info ->
info.index < messages.itemCount &&
messages.peek(info.index) is ChatListItem.UnreadDivider &&
messages.peek(info.index)?.holdsUnreadDivider == true &&
info.offset >= band.first && info.offset + info.size <= band.last
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,13 @@ internal fun MessageRow(
UnreadDividerRow(count = item.count, date = item.date)
}

is ChatListItem.EncryptedMarker -> Box(insertionModifier) {
EncryptedMarkerRow(
above = item.above,
onClick = { onAction(ChatAction.OpenEncryptionInfo) },
)
}

is ChatListItem.ContentBubble -> {
val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer)
// Bound to a local: `sender` is a property of another module's public API, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class ChatGroupAnalyticsTest {

private fun createViewModel(): ChatViewModel = ChatViewModel(
chatCoordinator = chatCoordinator,
e2eePolicy = E2eePolicy(null),
e2eePolicy = E2eePolicy(),
contactCoordinator = contactCoordinator,
contactPaymentDelegate = contactPaymentDelegate,
tipPaymentDelegate = tipPaymentDelegate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class ChatGroupCashLinkTest {

private fun createViewModel(): ChatViewModel = ChatViewModel(
chatCoordinator = chatCoordinator,
e2eePolicy = E2eePolicy(null),
e2eePolicy = E2eePolicy(),
contactCoordinator = contactCoordinator,
contactPaymentDelegate = contactPaymentDelegate,
tipPaymentDelegate = tipPaymentDelegate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class ChatOpenTranscriptTest {

private fun createViewModel(): ChatViewModel = ChatViewModel(
chatCoordinator = chatCoordinator,
e2eePolicy = E2eePolicy(null),
e2eePolicy = E2eePolicy(),
contactCoordinator = mockk(relaxed = true),
contactPaymentDelegate = mockk(relaxed = true),
tipPaymentDelegate = tipPaymentDelegate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class ChatSendFailureAnalyticsTest {

private fun createViewModel(): ChatViewModel = ChatViewModel(
chatCoordinator = chatCoordinator,
e2eePolicy = E2eePolicy(null),
e2eePolicy = E2eePolicy(),
contactCoordinator = contactCoordinator,
contactPaymentDelegate = contactPaymentDelegate,
tipPaymentDelegate = tipPaymentDelegate,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.flipcash.app.messenger.internal

import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.chat.UnreadBoundary
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.MessagePart
import com.flipcash.shared.chat.models.SeparatorConfig
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant

class EncryptedMarkerPlacementTest {

private val noon = Instant.fromEpochMilliseconds(1_700_000_000_000)

private fun row(id: Long, at: Instant = noon, part: MessagePart? = null) = ChatListItem.ContentBubble(
messageId = id,
contentIndex = 0,
content = MessageContent.Text("m$id"),
isFromSelf = false,
timestamp = at,
part = part,
)

private fun between(
newer: ChatListItem.ContentBubble,
older: ChatListItem.ContentBubble?,
oldestEncryptedId: Long?,
boundary: UnreadBoundary = UnreadBoundary.None,
) = separatorBetween(newer, older, boundary, SeparatorConfig.DayOnly, oldestEncryptedId)

/** Oldest first; the ids the marker sits above. */
private fun markedAbove(oldestEncryptedId: Long?, vararg oldestFirst: ChatListItem.ContentBubble): List<Long> {
val newestFirst = oldestFirst.reversed()
return newestFirst.indices.mapNotNull { i ->
val newer = newestFirst[i]
newer.messageId.takeIf {
between(newer, newestFirst.getOrNull(i + 1), oldestEncryptedId) is ChatListItem.EncryptedMarker
}
}
}

@Test
fun `the marker sits above the oldest encrypted message, below older plaintext`() =
assertEquals(listOf(3L), markedAbove(3, row(1), row(2), row(3), row(4)))

@Test
fun `no encrypted message, no marker`() = assertEquals(emptyList(), markedAbove(null, row(1), row(2)))

@Test
fun `a transcript encrypted from the start has the marker at the head, carrying the date`() {
assertEquals(listOf(1L), markedAbove(1, row(1), row(2)))
val marker = assertIs<ChatListItem.EncryptedMarker>(between(row(1), older = null, oldestEncryptedId = 1))
assertEquals(ChatListItem.DateSeparator(noon), marker.above)
}

@Test
fun `a day change in the same gap is drawn above the marker`() {
val marker = assertIs<ChatListItem.EncryptedMarker>(
between(row(2, at = noon + 1.days), row(1), oldestEncryptedId = 2),
)
assertEquals(ChatListItem.DateSeparator(noon + 1.days), marker.above)
}

@Test
fun `the same day keeps the marker alone`() =
assertNull(assertIs<ChatListItem.EncryptedMarker>(between(row(2), row(1), oldestEncryptedId = 2)).above)

@Test
fun `the unread divider in the same gap is drawn above the marker`() {
val marker = assertIs<ChatListItem.EncryptedMarker>(
between(row(2), row(1), oldestEncryptedId = 2, boundary = UnreadBoundary.At(1, 1)),
)
assertEquals(ChatListItem.UnreadDivider(1), marker.above)
assertEquals(true, marker.holdsUnreadDivider)
}

@Test
fun `the marker never splits the rows of one message`() {
val card = row(2, part = MessagePart.Card)
val leading = row(2, part = MessagePart.Leading)
assertNull(between(newer = card, older = leading, oldestEncryptedId = 2))
assertIs<ChatListItem.EncryptedMarker>(between(newer = leading, older = row(1), oldestEncryptedId = 2))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ sealed interface ChatAction {
*/
data object AddCash : ChatAction

/** Opens the DM encryption explainer, from the transcript's Encrypted marker. */
data object OpenEncryptionInfo : ChatAction

/**
* Opens the group a link card names, pushed over this chat so Back returns here. The pushed
* screen gates itself; a card never joins or buys on the reader's behalf.
Expand Down
Loading
Loading