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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.tipping.internal.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.CircleShape
Expand All @@ -10,6 +11,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.ui.ChatListRow
import com.flipcash.shared.chat.ui.ChatRowSubtitle
import com.flipcash.shared.chat.ui.ChatRowTrailing
Expand All @@ -18,6 +23,7 @@ import com.flipcash.shared.chat.ui.MutedIndicator
import com.flipcash.shared.chat.ui.SubtitleText
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.theme.CodeTheme
import kotlin.time.Clock


@Composable
Expand Down Expand Up @@ -85,4 +91,43 @@ internal fun TipChatRow(
showDivider = showDivider,
onClick = onClick,
)
}
}

@Composable
@Preview
@PreviewWrapper(FlipcashThemeWrapper::class)
private fun PreviewTipChatRowUnread() {
val now = Clock.System.now()
Column {
TipChatRow(
chat = ConversationReference(
chatId = ChatId(byteArrayOf(1)),
displayName = "Sally The Streamer",
lastMessagePreview = "Thanks!",
lastActivity = now,
),
onClick = {},
)
TipChatRow(
chat = ConversationReference(
chatId = ChatId(byteArrayOf(2)),
displayName = "Grace Hopper",
lastMessagePreview = "Sent you $1.00 in Dollars",
lastActivity = now,
unreadCount = 2,
),
onClick = {},
)
TipChatRow(
chat = ConversationReference(
chatId = ChatId(byteArrayOf(3)),
displayName = "Moony",
lastMessagePreview = "Chloe Anderson: Agreed!",
lastActivity = now,
unreadCount = 15,
),
showDivider = false,
onClick = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ class TipChatRowScreenshotTest {
lastMessagePreview = "Sent you $0.25 in Dollars",
lastActivity = at,
),
// A two-digit unread count widens the pill rather than overflowing the circle.
ConversationReference(
chatId = ChatId(byteArrayOf(4)),
displayName = "Moony",
handle = null,
lastMessagePreview = "Chloe Anderson: Agreed!",
lastActivity = at,
unreadCount = 15,
),
)

composeRule.mainClock.autoAdvance = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
Expand All @@ -21,9 +22,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.flipcash.core.R
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.Badge
import com.getcode.util.formatLocalized
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
Expand Down Expand Up @@ -92,15 +96,35 @@ fun ChatListRow(
}
}

/**
* The chat's unread count in a pill beside the timestamp (node 10329:8245): dark text on the
* indicator colour, 18dp tall, a circle for one digit and widening for more. A null [count] is
* unread by a number that can't be known, and draws a plain dot rather than a guess.
*/
@Composable
fun UnreadBadge(count: Int, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(CodeTheme.dimens.grid.x2)
.background(
color = CodeTheme.colors.indicator,
shape = CircleShape,
),
fun UnreadBadge(count: Int?, modifier: Modifier = Modifier) {
if (count == null) {
Box(
modifier = modifier
.size(CodeTheme.dimens.grid.x2)
.background(color = CodeTheme.colors.indicator, shape = CircleShape),
)
return
}
Badge(
modifier = modifier,
count = count,
color = CodeTheme.colors.indicator,
contentColor = CodeTheme.colors.background,
// Literal 4dp: `staticGrid.x1` is 5dp, and `grid.x1` scales with window width.
contentPadding = PaddingValues(horizontal = 4.dp),
// Fixed rather than padded: Avenir's 12sp line is ~16dp and doesn't shrink for a smaller
// line height, so vertical padding lands at 20dp instead of the design's 18.
height = 18.dp,
textStyle = CodeTheme.typography.caption.copy(
fontWeight = FontWeight.SemiBold,
letterSpacing = (-0.06).em,
),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@ import kotlin.time.Instant
/**
* Trailing cluster of a chat row's title line — reusable by any conversation-style row
* (contacts, tip chats, …): the last-activity timestamp, then an unread badge, or an
* open-chevron when [canOpen] and there's nothing unread.
* open-chevron when [canOpen] and there's nothing unread. A null [unreadCount] is unread by a
* number that can't be known, and draws the badge without one.
*
* Relies on [UnreadBadge] and [formatLastActivity] from `ChatListRow`.
*/
@Composable
fun RowScope.ChatRowTrailing(
lastActivity: Instant?,
unreadCount: Int,
unreadCount: Int?,
canOpen: Boolean,
) {
if (lastActivity != null) {
val activityTextColor by animateColorAsState(
if (unreadCount > 0) CodeTheme.colors.indicator else CodeTheme.colors.textSecondary
if (unreadCount != 0) CodeTheme.colors.indicator else CodeTheme.colors.textSecondary
)
Text(
text = formatLastActivity(lastActivity),
Expand All @@ -41,12 +42,11 @@ fun RowScope.ChatRowTrailing(
)
}

if (unreadCount > 0) {
if (unreadCount != 0) {
// No start padding: the title row's spacing already puts the design's 4dp between the
// timestamp and the pill.
UnreadBadge(
modifier = Modifier.padding(
start = CodeTheme.dimens.grid.x1,
end = CodeTheme.dimens.grid.x1,
),
modifier = Modifier.padding(end = CodeTheme.dimens.grid.x1),
count = unreadCount,
)
} else if (canOpen) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ data class ConversationReference(
val hasMessages: Boolean = true,
/** The chat's last-activity timestamp; drives recency sorting and the row's trailing timestamp. */
val lastActivity: Instant? = null,
val unreadCount: Int = 0,
/** Messages past the viewer's READ pointer: 0 when read, null when unread by an unknown count. */
val unreadCount: Int? = 0,
val isTyping: Boolean = false,
/**
* The viewer's own state on this chat, which is where the row's mute comes from.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ data class ChatState(
// Null until the feed has been read from the database once, so an empty list means "no
// chats" and never "not read yet".
val feed: List<ChatMetadata>? = null,
// The unread stamp on the message each chat's READ pointer names, keyed by chat and that
// message's id, read alongside [feed] so a row can count its unread messages. A chat whose
// pointed-at message isn't stored is absent.
val readStamps: Map<Pair<ChatId, Long>, Long> = emptyMap(),
// The same stamps fetched from the server for pointed-at messages the device doesn't store,
// held for the session and never written to the message table: a lone message there would read
// as part of the transcript. [readStamps] wins where both hold a key.
val fetchedReadStamps: Map<Pair<ChatId, Long>, Long> = emptyMap(),
val typingIndicators: Map<ChatId, Set<ActiveTypist>> = emptyMap(),
val reactionOverlays: Map<ChatId, Map<Long, ReactionSummary>> = emptyMap(),
val feedSyncState: FeedSyncState = FeedSyncState.Idle,
Expand All @@ -19,7 +27,8 @@ data class ChatState(

data class ChatSummary(
val metadata: ChatMetadata,
val unreadCount: Int,
/** Messages past the viewer's READ pointer: 0 when read, null when unread by an unknown count. */
val unreadCount: Int?,
)

data class ActiveTypist(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,43 @@ internal fun isRenderable(metadata: ChatMetadata, selfId: ID?, selfPhone: String
return isDmAddressable(metadata.type, otherMember.userProfile)
}

/** The signed-in user's READ pointer in [metadata], or null when there is none. */
internal fun selfReadPointer(metadata: ChatMetadata, selfId: ID?): Long? =
metadata.members
.firstOrNull { it.userId == selfId }
?.pointers
?.firstOrNull { it.type == PointerType.READ }
?.value

/**
* 1 when the newest visible message is past the signed-in user's READ pointer and someone else
* sent it, 0 otherwise. The feed shows a splat rather than a number, so counting past one buys
* nothing.
* How many messages past the signed-in user's READ pointer the chat holds: 0 when it's read, null
* when it's unread but the number can't be known.
*
* The server stamps each message with a running count of unread-eligible messages (`unreadSeq`),
* so the count is the newest message's stamp less the stamp on the message the pointer names,
* looked up through [unreadSeqAt]. With no pointer nothing has been read and the newest stamp is
* the count. A pointer whose message isn't stored (after a database rebuild, say) leaves the count
* unknown, as does a newest stamp at or below the pointer's: then the newest message isn't
* unread-eligible, and a guess would be wrong either way.
*
* A group's roster is paged: the members the device holds are a slice, and the signed-in user's
* own row may not be in it. Falling back to a pointer of zero there would splat every group until
* the slice caught up, so an absent self row means "not known yet" for a group. On a DM both
* members always arrive together, so absence keeps its old meaning and the count is unchanged.
* The chat is read when the newest visible message is the user's own or isn't past the pointer. A
* group's roster is paged: the members the device holds are a slice, and the signed-in user's own
* row may not be in it. Falling back to no pointer there would mark every group unread until the
* slice caught up, so an absent self row means "not known yet" for a group. On a DM both members
* always arrive together, so absence means nothing has been read.
*/
internal fun unreadCount(metadata: ChatMetadata, selfId: ID?): Int {
val self = metadata.members.firstOrNull { it.userId == selfId }
if (self == null && metadata.type == ChatType.GROUP) return 0
internal fun unreadCount(
metadata: ChatMetadata,
selfId: ID?,
unreadSeqAt: (messageId: Long) -> Long?,
): Int? {
if (metadata.type == ChatType.GROUP && metadata.members.none { it.userId == selfId }) return 0

val readPointer = self?.pointers?.firstOrNull { it.type == PointerType.READ }?.value ?: 0L
val lastMessage = metadata.lastMessage ?: return 0
return if (lastMessage.messageId > readPointer && lastMessage.senderId != selfId) 1 else 0
if (lastMessage.senderId == selfId) return 0
val readPointer = selfReadPointer(metadata, selfId)
if (lastMessage.messageId <= (readPointer ?: 0L)) return 0

val readStamp = if (readPointer == null) 0L else unreadSeqAt(readPointer) ?: return null
return (lastMessage.unreadSeq - readStamp).takeIf { it > 0 }?.toInt()
}
Loading
Loading