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
5 changes: 5 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,11 @@
<string name="label_chat_preview_sentMessage">You: %1$s</string>
<string name="label_isTyping">Is Typing…</string>

<string name="label_edited">Edited</string>
<string name="label_messageDeletedByYou">You deleted this message</string>
<string name="label_messageDeleted">This message was deleted</string>
<string name="label_chat_preview_deletedMessage">Message deleted</string>

<string name="label_unknownContact">Unknown Contact</string>
<string name="title_allowFullContactAccess">Allow Full Contact Access</string>
<string name="subtitle_allowFullContactAccess">Make sure you can send cash and identify people you know</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ internal class ChatViewModel @Inject constructor(
timestamp = message.timestamp,
receiptStatus = receiptStatus,
pendingClientIdHex = message.pendingClientIdHex,
isEdited = message.lastEditedTs != null,
// A null author is a moderation removal, which reads as someone else's.
deletedByViewer = (enriched as? MessageContent.Deleted)?.deletedBy
?.let { it == userManager.accountId } == true,
)
}
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import androidx.compose.runtime.CompositionLocalProvider
import com.flipcash.shared.chat.models.ChatAction
Expand Down Expand Up @@ -401,10 +402,18 @@ private fun HandleMessageReads(
}
}

/**
* A tombstone anchors nothing. The receipt describes the delivery of a message that is no longer
* there, so leaving it attached would caption a deleted bubble with "Read".
*/
private val ChatListItem.ContentBubble.carriesReceipt: Boolean
get() = isFromSelf && content !is MessageContent.Deleted

private fun effectiveReceiptStatus(
bubble: ChatListItem.ContentBubble,
otherReadPointer: MessagePointer?,
): ReceiptStatus? {
if (!bubble.carriesReceipt) return null
val base = bubble.receiptStatus ?: return null
val pointerValue = otherReadPointer?.value ?: 0L
if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) {
Expand All @@ -421,20 +430,33 @@ private fun effectiveReceiptStatus(
* At group boundaries, labels are suppressed when the nearest self-group
* below already shows the same status (avoids duplicate "Read" labels).
*/
/** The nearest bubble below [index], stepping over the viewer's own tombstones. */
private fun receiptNeighbourBelow(
index: Int,
messages: LazyPagingItems<ChatListItem>,
): ChatListItem.ContentBubble? {
for (i in (index - 1) downTo 0) {
val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null
if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue
return bubble
}
return null
}

private fun shouldShowReceiptLabel(
index: Int,
item: ChatListItem.ContentBubble,
messages: LazyPagingItems<ChatListItem>,
otherReadPointer: MessagePointer?,
): Boolean {
if (!item.isFromSelf) return false
if (!item.carriesReceipt) 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
val below = if (index > 0) messages.peek(index - 1) else null
val belowBubble = below as? ChatListItem.ContentBubble
// index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self
// group for bubble shaping, so it is stepped over here rather than treated as a group boundary.
val belowBubble = receiptNeighbourBelow(index, messages)

// Within a self-group: show at intra-group status boundaries only
if (belowBubble != null && belowBubble.isFromSelf) {
Expand All @@ -461,7 +483,7 @@ private fun shouldShowReceiptLabel(
for (i in (index - 1) downTo 0) {
val peek = messages.peek(i) ?: break
val bubble = peek as? ChatListItem.ContentBubble ?: continue
if (bubble.isFromSelf) return effectiveReceiptStatus(bubble, otherReadPointer) != status
if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status
}
return true
}
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/shared/chat-ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ dependencies {
implementation(project(":apps:flipcash:shared:theme"))

testImplementation(libs.robolectric)
testImplementation(libs.bundles.unit.testing)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@ sealed interface ChatListItem {
val timestamp: Instant,
val receiptStatus: ReceiptStatus? = null,
val pendingClientIdHex: String? = null,
/** Drives the corner-pinned "Edited" marker. */
val isEdited: Boolean = false,
/** Chooses between "You deleted this message" and "This message was deleted". */
val deletedByViewer: Boolean = false,
) : ChatListItem {
override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex"

// A tombstone shares the text bubble's content type on purpose: deleting a message is an
// in-place update of a row the list already holds, and giving it a type of its own would
// make the list drop that row and insert a new one.
override val itemContentType: Any = when (content) {
is MessageContent.Text -> "text-bubble"
is MessageContent.Deleted -> "text-bubble"
is MessageContent.Cash -> "cash-bubble"
is MessageContent.Deleted -> "deleted-message"
is MessageContent.Media -> "media"
is MessageContent.Reply -> "reply-message"
is MessageContent.System -> "system-message"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ private fun ChatSummary.formatPreview(
resources.getString(previewRes, label)
}

// The conversation list says a message is gone rather than going blank, which would
// otherwise read as the whole conversation having no messages.
is MessageContent.Deleted ->
resources.getString(R.string.label_chat_preview_deletedMessage)

// TODO:
is MessageContent.Deleted -> null
is MessageContent.Media -> null
is MessageContent.Reply -> null
is MessageContent.System -> null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,31 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
Expand All @@ -59,6 +69,7 @@ import com.getcode.ui.core.addIf
enum class BubblePosition { Solo, First, Middle, Last }

private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f
private val EDITED_MARKER_GAP = 6.dp
private const val CASH_BUBBLE_MAX_WIDTH_FRACTION = 0.64f

@Composable
Expand All @@ -72,7 +83,7 @@ fun ContentBubble(
val bubbleMaxWidth = when (item.content) {
is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION
is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION
is MessageContent.Deleted -> maxWidth
is MessageContent.Deleted -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION
is MessageContent.Media -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION
is MessageContent.Reply -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION
is MessageContent.System -> maxWidth
Expand All @@ -89,6 +100,24 @@ fun ContentBubble(
isFromSelf = item.isFromSelf,
position = position,
maxWidth = bubbleMaxWidth,
isEdited = item.isEdited,
)

// A tombstone is a text bubble with different words. Rendering it through the same
// composable is what makes a delete an in-place update of the row already on screen.
is MessageContent.Deleted -> TextBubble(
modifier = modifier,
text = stringResource(
if (item.deletedByViewer) {
R.string.label_messageDeletedByYou
} else {
R.string.label_messageDeleted
}
),
isFromSelf = item.isFromSelf,
position = position,
maxWidth = bubbleMaxWidth,
isTombstone = true,
)

is MessageContent.Cash -> CashBubble(
Expand All @@ -106,7 +135,6 @@ fun ContentBubble(
)

// TODO
is MessageContent.Deleted -> Unit
is MessageContent.Media -> Unit
is MessageContent.Reply -> Unit
is MessageContent.System -> Unit
Expand All @@ -115,30 +143,95 @@ fun ContentBubble(
}
}

private const val EDITED_MARKER_SLOT = "edited-marker"

@Composable
private fun TextBubble(
text: String,
isFromSelf: Boolean,
position: BubblePosition,
maxWidth: Dp,
modifier: Modifier = Modifier,
isEdited: Boolean = false,
isTombstone: Boolean = false,
) {
Bubble(isFromSelf, position, maxWidth, modifier) {
val linkStyle = SpanStyle(
color = CodeTheme.colors.textMain,
textDecoration = TextDecoration.Underline,
)
val richText = rememberRichText(
text = text,
annotators = listOf(UrlAnnotator(linkStyle)),
// A tombstone carries no link and nothing worth selecting; it is a notice, not a message.
val body = if (isTombstone) {
AnnotatedString(text)
} else {
rememberRichText(text = text, annotators = listOf(UrlAnnotator(linkStyle)))
}
val bodyStyle = CodeTheme.typography.textMedium.copy(
fontWeight = FontWeight.Medium,
fontStyle = if (isTombstone) FontStyle.Italic else FontStyle.Normal,
)
SelectionContainer {
val bodyColor = if (isTombstone) {
CodeTheme.colors.textSecondary
} else {
CodeTheme.colors.textMain
}

val markerLabel = stringResource(R.string.label_edited)
val markerStyle = CodeTheme.typography.caption

// The marker is pinned to the bubble's bottom-trailing corner rather than laid out after
// the text, so the body has to leave a hole of exactly the marker's width. An empty
// placeholder in the text flow does that: it sits on the last line where there is room and
// wraps onto its own line where there isn't, without dragging the last word along with it.
// It draws nothing and holds no text, so selection and the accessibility tree see only the
// real marker below.
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val reservation = remember(markerLabel, markerStyle, density) {
with(density) {
(measurer.measure(markerLabel, markerStyle).size.width.toDp() + EDITED_MARKER_GAP).toSp()
}
}

val laidOut = if (isEdited) {
buildAnnotatedString {
append(body)
appendInlineContent(EDITED_MARKER_SLOT, "\u2007")
}
} else {
body
}
val inlineContent = if (isEdited) {
mapOf(
EDITED_MARKER_SLOT to InlineTextContent(
Placeholder(
width = reservation,
height = 1.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom,
),
) { },
)
} else {
emptyMap()
}

val bodyText = @Composable {
Text(
text = laidOut,
inlineContent = inlineContent,
style = bodyStyle,
color = bodyColor,
)
}

if (isTombstone) bodyText() else SelectionContainer { bodyText() }

if (isEdited) {
Text(
text = richText,
style = CodeTheme.typography.textMedium.copy(
fontWeight = FontWeight.Medium
),
color = CodeTheme.colors.textMain,
modifier = Modifier.align(Alignment.BottomEnd),
text = markerLabel,
style = markerStyle,
color = CodeTheme.colors.textSecondary,
)
}
}
Expand Down Expand Up @@ -411,6 +504,45 @@ private fun Preview_TextBubble_Incoming() {
)
}

@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun Preview_TextBubble_Edited() {
TextBubble(
text = "Hey! How's it going?",
isFromSelf = true,
position = BubblePosition.Solo,
maxWidth = 300.dp,
isEdited = true,
)
}

@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun Preview_TextBubble_EditedLongMessage() {
TextBubble(
text = "A long enough message that the last line has no room left for the marker, so the reservation wraps onto a line of its own and the bubble grows to fit it.",
isFromSelf = true,
position = BubblePosition.Solo,
maxWidth = 300.dp,
isEdited = true,
)
}

@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
private fun Preview_TextBubble_Tombstone() {
TextBubble(
text = "You deleted this message",
isFromSelf = true,
position = BubblePosition.Solo,
maxWidth = 300.dp,
isTombstone = true,
)
}

@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
@Composable
Expand Down
Loading
Loading