refactor(cordn): draw the chat on the shared bubble and chip components

The cordn room was the only chat in Amethyst that did not go through
ChatBubbleLayout. It drew a flat Column per message, which meant no
bubble, no side by author, no grouped corners, and — the part that
actually breaks muscle memory — a plain tap that opened the action
sheet, where every other chat reserves tap for nothing, long-press for
the menu, double-tap for a reaction and a horizontal swipe for a reply.

CordnMessageRow now calls ChatBubbleLayout and fills its five slots from
the envelope and the annotation fold. The gesture vocabulary, the
grouped bubble shapes, the press scale and the swipe-to-reply affordance
all come with the layout.

The reason cordn had forked in the first place is real but narrower than
it looked: the shared slot *contents* (ChatReactionChips,
ChatMessageActionSheet, ChatMessageFooter, DrawAuthorInfo) all take a
Note, and a Note comes from LocalCache, which a cordn room may never
enter. ChatBubbleLayout itself names neither — it is pure slots over
primitives, so the layout was always shareable.

Four primitives underneath it were Note-free too, just private. They are
now internal and cordn calls them directly rather than restating them:

  - ReactionChipView / ReactionChip — the engagement strip, which brings
    the "you reacted" highlight the cordn chips never had.
  - ActionTile / TileRow / SectionDivider — the sheet is icon tiles now
    instead of four rows of plain text.
  - CHAT_GROUP_WINDOW_SECONDS — cordn had invented a 5 minute window
    against the shared 10, so bursts broke in different places in the
    two chats.

Two small extractions were needed: ChatChipFlowRow out of
RenderChatReactionChips, and jumboEmojiFontSize out of
RenderRegularTextNote. Both are lifts, and the visibility widenings are
one-word changes, so the DM and Marmot paths render exactly as before.

Two gaps this closes were missing rather than differently styled:

  - Messages now carry a timestamp. The room showed no per-message time
    at all, only day separators, so nothing said when anything was said.
  - Grouping now yields real ChatGroupPosition values, so a burst squares
    off its corners instead of only hiding the repeated author line.

cordnGroupPositionFor is pure and easy to get backwards in a
reverse-laid-out list, so it is tested by the ends of a run by name.
Mutation-checked: inverting TOP/BOTTOM, restoring the old 5 minute
window and dropping the midnight check each fail exactly one test.

Known gaps, unchanged by this: cordn has no retract annotation, so the
strip cannot un-react (a chip long-press does nothing rather than
re-sending), and there is no "who reacted" detail sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-24 12:40:00 +00:00
parent fb31d7e01b
commit ab643fbd6f
8 changed files with 789 additions and 337 deletions
@@ -29,7 +29,6 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -43,13 +42,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -66,12 +63,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -87,19 +82,15 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.resources.today
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.model.cordn.CordnMediaService
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences
@@ -110,10 +101,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* One cordn room.
@@ -171,7 +158,6 @@ private fun CordnGroupChat(
val draft by room.draft.collectAsStateWithLifecycle()
var replyingTo by remember { mutableStateOf<CordnDeliveredMessage?>(null) }
var editing by remember { mutableStateOf<CordnDeliveredMessage?>(null) }
var acting by remember { mutableStateOf<CordnDeliveredMessage?>(null) }
var attaching by remember { mutableStateOf(false) }
var attachError by remember { mutableStateOf<String?>(null) }
@@ -281,28 +267,61 @@ private fun CordnGroupChat(
val rows = messages.asReversed()
itemsIndexed(rows, key = { _, it -> it.envelope.id }) { index, message ->
// The row BELOW this one on screen, which in a reversed
// list is the next index — i.e. the older message.
// `rows` runs newest-first and the list is reverse-laid-out,
// so the next index is the older message and renders above.
val older = rows.getOrNull(index + 1)
val newer = rows.getOrNull(index - 1)
CordnMessageRow(
message = message,
// Grouped when the same person keeps talking inside a
// few minutes: the avatar and name are repeated once
// per burst instead of once per line, which is most of
// what makes a wall of messages readable.
showAuthor = !message.follows(older),
room = room,
annotations = annotations,
me = me,
accountViewModel = accountViewModel,
nav = nav,
// Grouped when the same person keeps talking inside the
// shared chat window: one avatar and name per burst
// instead of per line, and the bubbles of a burst square
// off against each other — which is most of what makes a
// wall of messages readable.
groupPosition =
remember(newer?.envelope?.id, message.envelope.id, older?.envelope?.id) {
cordnGroupPositionFor(newer, message, older)
},
// The edit if there is one, and nothing at all if the
// message was withdrawn: rendering the original text of
// a deleted message would defeat the deletion.
text = if (annotations.isDeleted(message.envelope.id)) null else annotations.contentOf(message.envelope.id),
isEdited = annotations.isEdited(message.envelope.id),
onClick = { acting = message },
accountViewModel = accountViewModel,
nav = nav,
onReply = {
replyingTo = message
editing = null
},
onEdit = {
editing = message
replyingTo = null
room.draft.value = annotations.contentOf(message.envelope.id).orEmpty()
},
onDelete = {
// Through trySend like the rest: a deletion is an
// annotation, and an annotation of your own comes back
// as an Echo too, so deleting your own message used to
// look like nothing had happened until someone else's
// traffic refreshed the fold.
scope.launch { trySend { it.post(room.gid, deleteTo = message.target()) } }
},
onTogglePin = {
val pinned = annotations.isPinned(message.envelope.id)
scope.launch {
trySend {
it.post(
gid = room.gid,
pinTo = message.target(),
pinOp = if (pinned) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD,
)
}
}
},
onReact = { emoji ->
scope.launch { trySend { it.post(room.gid, emoji, reactionTo = message.target()) } }
},
@@ -407,49 +426,6 @@ private fun CordnGroupChat(
)
}
}
val actingOn = acting
if (actingOn != null) {
val message = actingOn
MessageActions(
message = message,
isMine = message.envelope.pubKey == me,
isPinned = annotations.isPinned(message.envelope.id),
onDismiss = { acting = null },
onReply = {
replyingTo = message
editing = null
acting = null
},
onEdit = {
editing = message
replyingTo = null
room.draft.value = annotations.contentOf(message.envelope.id).orEmpty()
acting = null
},
onDelete = {
acting = null
// Through trySend like the rest: a deletion is an annotation,
// and an annotation of your own comes back as an Echo too, so
// deleting your own message used to look like nothing had
// happened until someone else's traffic refreshed the fold.
scope.launch { trySend { it.post(room.gid, deleteTo = message.target()) } }
},
onTogglePin = {
val pinned = annotations.isPinned(message.envelope.id)
acting = null
scope.launch {
trySend {
it.post(
gid = room.gid,
pinTo = message.target(),
pinOp = if (pinned) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD,
)
}
}
},
)
}
}
/** The target fields `CordnMessageReferences` needs, straight off a delivery. */
@@ -507,56 +483,6 @@ private fun PinnedRibbon(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MessageActions(
message: CordnDeliveredMessage,
isMine: Boolean,
isPinned: Boolean,
onDismiss: () -> Unit,
onReply: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
ActionRow(stringRes(R.string.cordn_action_reply), onReply)
// Pinning is any member's (§5.1), so it is offered on every
// message rather than only on your own.
ActionRow(
label = stringRes(if (isPinned) R.string.cordn_action_unpin else R.string.cordn_action_pin),
onClick = onTogglePin,
)
// Edit and delete are author-only, and the manager refuses them
// for anyone else. Hiding them here is the same rule, stated
// where it stops being a surprise.
if (isMine) {
ActionRow(stringRes(R.string.cordn_action_edit), onEdit)
ActionRow(stringRes(R.string.cordn_action_delete), onDelete, isDestructive = true)
}
}
}
}
@Composable
private fun ActionRow(
label: String,
onClick: () -> Unit,
isDestructive: Boolean = false,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 14.dp),
)
}
@Composable
private fun ComposerBanner(
label: String,
@@ -587,198 +513,6 @@ private fun CordnChatTopBar(
)
}
/** How long a burst from one sender stays one burst. */
private const val GROUPING_WINDOW_SECONDS = 5 * 60
/**
* Whether this message continues [older]'s burst — same sender, close in time.
*
* Time as well as sender, because a reply hours later to your own last message
* is a new thought, and hiding the name on it reads as though the conversation
* never paused.
*/
private fun CordnDeliveredMessage.follows(older: CordnDeliveredMessage?): Boolean {
if (older == null) return false
if (older.envelope.pubKey != envelope.pubKey) return false
if (!sameDayAs(older)) return false
return envelope.createdAt - older.envelope.createdAt <= GROUPING_WINDOW_SECONDS
}
/** Whether both fall on the same local calendar day. A null [older] is a new day. */
private fun CordnDeliveredMessage.sameDayAs(older: CordnDeliveredMessage?): Boolean {
if (older == null) return false
return localDayOf(envelope.createdAt) == localDayOf(older.envelope.createdAt)
}
private fun localDayOf(epochSeconds: Long): LocalDate = Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
/**
* The day a run of messages belongs to.
*
* Without one, a conversation is an undivided column and "yesterday evening"
* and "this morning" sit flush against each other.
*/
@Composable
private fun DaySeparator(createdAt: Long) {
val day = remember(createdAt) { localDayOf(createdAt) }
val today = remember { LocalDate.now(ZoneId.systemDefault()) }
val label =
when (day) {
today -> stringRes(Res.string.today)
today.minusDays(1) -> stringRes(R.string.cordn_chat_yesterday)
// Year included only when it is not this one: printing 2026 on
// every divider all year is noise.
else ->
day.format(
DateTimeFormatter.ofPattern(
if (day.year == today.year) "d MMM" else "d MMM yyyy",
),
)
}
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
HorizontalDivider(Modifier.weight(1f))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(Modifier.weight(1f))
}
}
@Composable
private fun CordnMessageRow(
message: CordnDeliveredMessage,
room: CordnGroupChatroom,
annotations: CordnAnnotationIndex,
me: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
text: String?,
isEdited: Boolean,
showAuthor: Boolean,
onClick: () -> Unit,
onReact: (String) -> Unit,
) {
val mentionsMe = remember(text, me) { text != null && me in CordnMentions.mentioned(text) }
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
// A mention is the one reason to pick a message out of a wall of
// them. Read from the content rather than from a `p` tag: a tag is
// a claim the sender makes about who they addressed, while the
// text is what everyone in the room actually sees.
.background(if (mentionsMe) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent)
.padding(vertical = 6.dp, horizontal = if (mentionsMe) 6.dp else 0.dp),
) {
// Name and face, not eight hex characters. The room used to attribute
// every message to a prefix of the sender's key while the inbox row
// that leads into it, and mentions inside the text below, both
// resolved properly — so the one place a sender is named most often
// was the one place that did not name them.
//
// observeUserNameByHex falls back to exactly that hex prefix until the
// profile arrives, so nothing regresses while it loads.
if (showAuthor) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserPicture(
userHex = message.envelope.pubKey,
size = 24.dp,
accountViewModel = accountViewModel,
nav = nav,
)
Text(
text = observeUserNameByHex(message.envelope.pubKey, accountViewModel),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val thread = CordnMessageReferences.thread(message.envelope.tags)
val parent = thread?.let { annotations.byId[it.parentId] }
if (parent != null) {
Text(
text = stringRes(R.string.cordn_action_in_reply_to, annotations.contentOf(parent.envelope.id).orEmpty().take(60)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (text == null) {
Text(
text = stringRes(R.string.cordn_message_deleted),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
MessageBody(text, accountViewModel, nav)
if (isEdited) {
Text(
text = stringRes(R.string.cordn_message_edited),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Only on a live message: a deleted one must not keep offering its
// attachment, and the blob is still on the host either way.
if (text != null) {
CordnMediaTag.parseAll(message.envelope.tags).forEach { attachment ->
CordnAttachment(attachment, room, accountViewModel)
}
}
Reactions(annotations.reactions[message.envelope.id].orEmpty(), onReact)
}
}
/**
* The reaction chips, plus the one quick way to add one.
*
* A reaction is a set of pubkeys per emoji in the fold, so the count is the
* set size — a member who reacted twice with the same emoji counts once, which
* is what the index already guarantees and what a naive message count would
* get wrong on a re-sync.
*/
@Composable
private fun Reactions(
reactions: Map<String, Set<String>>,
onReact: (String) -> Unit,
) {
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
reactions.forEach { (emoji, who) ->
AssistChip(
onClick = { onReact(emoji) },
label = { Text("$emoji ${who.size}", style = MaterialTheme.typography.labelSmall) },
)
}
if (reactions.isEmpty()) {
TextButton(onClick = { onReact(DEFAULT_REACTION) }) {
Text(DEFAULT_REACTION, style = MaterialTheme.typography.labelMedium)
}
}
}
}
/** What the one-tap reaction sends. Anything else goes through a picker later. */
private const val DEFAULT_REACTION = "\uD83D\uDC4D"
@Composable
private fun CordnComposer(
draft: String,
@@ -826,7 +560,7 @@ private fun CordnComposer(
* reading one puts no part of this conversation into it.
*/
@Composable
private fun MessageBody(
internal fun MessageBody(
text: String,
accountViewModel: AccountViewModel,
nav: INav,
@@ -897,7 +631,7 @@ private suspend fun sendAttachment(
* user's.
*/
@Composable
private fun CordnAttachment(
internal fun CordnAttachment(
attachment: CordnMediaAttachment,
room: CordnGroupChatroom,
accountViewModel: AccountViewModel,
@@ -0,0 +1,566 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.cordn.CordnMentions
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.today
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ActionTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatChipFlowRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChipView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.SectionDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.TileRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.authorNameColorFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.CHAT_GROUP_WINDOW_SECONDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.math.abs
/**
* A cordn message, drawn as an Amethyst chat bubble.
*
* Everything structural here is the app's shared chat furniture — [ChatBubbleLayout]
* for the bubble, its grouping shapes and its whole gesture vocabulary (long-press for
* the action sheet, double-tap to react, swipe toward the centre to reply),
* [ReactionChipView] for the engagement strip, [ActionTile] for the sheet. A cordn room
* used to draw its own flat rows, which meant the one chat in the app where a tap
* opened a menu and nothing could be swiped.
*
* What cordn cannot share is the *content* of those slots. The shared fillings
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatReactionChips],
* `ChatMessageActionSheet`, `ChatMessageFooter`, `DrawAuthorInfo`) all take a `Note`,
* and a `Note` comes from `LocalCache` — which nothing in a cordn room may enter, see
* the screen's KDoc. So the slots are filled from the envelope and the annotation fold
* instead, and the layout above them is the same one every other chat uses.
*/
@Composable
internal fun CordnMessageRow(
message: CordnDeliveredMessage,
room: CordnGroupChatroom,
annotations: CordnAnnotationIndex,
me: HexKey,
groupPosition: ChatGroupPosition,
text: String?,
isEdited: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
onReply: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit,
onReact: (String) -> Unit,
) {
val isMine = message.envelope.pubKey == me
val isPinned = annotations.isPinned(message.envelope.id)
val reactions = annotations.reactions[message.envelope.id].orEmpty()
// A deleted message keeps its place in the conversation but stops accepting
// annotations: replying to, editing or reacting to a withdrawal is meaningless,
// and the manager would refuse most of it anyway.
val isLive = text != null
// A mention is the one reason to pick a message out of a wall of them. Read from
// the content rather than from a `p` tag: a tag is a claim the sender makes about
// who they addressed, while the text is what everyone in the room actually sees.
val mentionsMe = remember(text, me) { text != null && me in CordnMentions.mentioned(text) }
val jumboCount = remember(text) { if (text != null) jumboEmojiCount(text) else 0 }
val mentionTint = MaterialTheme.colorScheme.primary.copy(alpha = MENTION_TINT_ALPHA)
Box(if (mentionsMe) Modifier.fillMaxWidth().background(mentionTint) else Modifier.fillMaxWidth()) {
ChatBubbleLayout(
isLoggedInUser = isMine,
isDraft = false,
innerQuote = false,
// Your own bubbles are right-aligned and tinted, so naming yourself over
// every burst of them is noise. Everyone else is named once per burst.
drawAuthorInfo = groupPosition.isFirstOfGroup && !isMine,
groupPosition = groupPosition,
transparentBubble = jumboCount > 0,
// A plain tap is a no-op, exactly as in every other Amethyst chat.
onClick = { false },
onDoubleTap =
if (isLive) {
{ onReact(DEFAULT_REACTION) }
} else {
null
},
onSwipeReply =
if (isLive) {
onReply
} else {
null
},
onAuthorClick = { nav.nav(Route.Profile(message.envelope.pubKey)) },
actionMenu = { onDismiss ->
CordnMessageActionSheet(
isMine = isMine,
isPinned = isPinned,
isLive = isLive,
accountViewModel = accountViewModel,
onDismiss = onDismiss,
onReply = onReply,
onEdit = onEdit,
onDelete = onDelete,
onTogglePin = onTogglePin,
onReact = onReact,
)
},
reactionsRow =
if (reactions.isEmpty()) {
null
} else {
{ CordnReactionChips(reactions, me, onReact) }
},
// Mirrors chatFooterHasMeta: the footer earns its row on the last message
// of a burst (for the time) or on any message carrying a marker of its own.
footerRow =
if (groupPosition.isLastOfGroup || isEdited || isPinned) {
{
CordnMessageFooter(
createdAt = message.envelope.createdAt,
isEdited = isEdited,
isPinned = isPinned,
showTime = groupPosition.isLastOfGroup,
)
}
} else {
null
},
drawAuthorLine = { CordnAuthorLine(message.envelope.pubKey, accountViewModel, nav) },
) { _ ->
CordnBubbleContents(
message = message,
room = room,
annotations = annotations,
text = text,
jumboCount = jumboCount,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
/** What a double-tap sends, and the fallback when the account lists no reaction choices. */
private const val DEFAULT_REACTION = "👍"
/** How strongly a message that mentions you tints its row. */
private const val MENTION_TINT_ALPHA = 0.10f
@Composable
private fun CordnBubbleContents(
message: CordnDeliveredMessage,
room: CordnGroupChatroom,
annotations: CordnAnnotationIndex,
text: String?,
jumboCount: Int,
accountViewModel: AccountViewModel,
nav: INav,
) {
val thread = remember(message.envelope.id) { CordnMessageReferences.thread(message.envelope.tags) }
val parent = thread?.let { annotations.byId[it.parentId] }
if (parent != null) {
Text(
text = stringRes(R.string.cordn_action_in_reply_to, annotations.contentOf(parent.envelope.id).orEmpty().take(60)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
when {
text == null ->
Text(
text = stringRes(R.string.cordn_message_deleted),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Emoji-only messages render bare and large, over a transparent bubble.
jumboCount > 0 -> Text(text = text.trim(), fontSize = jumboEmojiFontSize(jumboCount))
else -> MessageBody(text, accountViewModel, nav)
}
// Only on a live message: a deleted one must not keep offering its attachment,
// and the blob is still on the host either way.
if (text != null) {
CordnMediaTag.parseAll(message.envelope.tags).forEach { attachment ->
CordnAttachment(attachment, room, accountViewModel)
}
}
}
/**
* Name and face on the first bubble of a burst, in the shared chat author layout — so a
* cordn sender is drawn exactly like a DM sender, colour included. [authorNameColorFor]
* derives a stable hue from the pubkey, which is what makes authors scannable in a
* fast-moving room.
*
* [observeUserNameByHex] falls back to a hex prefix until the profile arrives. Looking a
* sender up for a display name is safe: a profile is public relay data the cache already
* holds, and reading one puts no part of this conversation into it.
*/
@Composable
private fun CordnAuthorLine(
pubKey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val name = observeUserNameByHex(pubKey, accountViewModel)
val isLightTheme = MaterialTheme.colorScheme.isLight
val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) }
UserDisplayNameLayout(
picture = {
UserPicture(
userHex = pubKey,
size = Size20dp,
accountViewModel = accountViewModel,
nav = nav,
)
},
name = {
Text(
text = name,
color = nameColor,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
/**
* The engagement strip riding the bubble's bottom border.
*
* A reaction is a set of pubkeys per emoji in the fold, so the count is the set size — a
* member who reacted twice with the same emoji counts once, which is what the index
* already guarantees and what a naive message count would get wrong on a re-sync.
*/
@Composable
private fun CordnReactionChips(
reactions: Map<String, Set<HexKey>>,
me: HexKey,
onReact: (String) -> Unit,
) {
val chips =
remember(reactions, me) {
reactions
.map { (emoji, who) -> ReactionChip(emoji, who.size, me in who) }
.sortedByDescending { it.count }
}
ChatChipFlowRow {
chips.forEach { chip ->
ReactionChipView(
chip = chip,
onClick = { onReact(chip.type) },
// The DM strip opens "who reacted" on a long press. cordn has no such
// sheet yet, and a long press that re-sent the reaction would be worse
// than one that does nothing.
onLongClick = {},
)
}
}
}
/**
* The bubble's bottom-corner footer: the markers this message carries, then the time on
* the last bubble of a burst. Until this existed a cordn room showed no per-message
* time at all — only the day separator — so nothing said when anything was said.
*/
@Composable
private fun CordnMessageFooter(
createdAt: Long,
isEdited: Boolean,
isPinned: Boolean,
showTime: Boolean,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isPinned) {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = stringRes(R.string.cordn_action_pin),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(StdHorzSpacer)
}
if (isEdited) {
Text(
text = stringRes(R.string.cordn_message_edited),
fontSize = Font12SP,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
Spacer(StdHorzSpacer)
}
if (showTime) {
ToggleableTimeAgoText(
timestamp = createdAt,
style = TimeAgoStyle.Short,
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP,
)
}
}
}
/**
* The long-press surface, built from the same [ActionTile]s as the DM sheet.
*
* It opens with the account's own reaction palette, because the strip below a bubble now
* appears only once a message *has* a reaction — without the palette, double-tap would
* be the only way to leave the first one, and an undiscoverable gesture is not an
* affordance.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CordnMessageActionSheet(
isMine: Boolean,
isPinned: Boolean,
isLive: Boolean,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onReply: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit,
onReact: (String) -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isLive) {
val choices by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle()
val palette = choices.ifEmpty { listOf(DEFAULT_REACTION) }
// Never drawn as "mine": cordn has no un-react, so this row adds a
// reaction rather than toggling one, and highlighting a choice you
// already used would promise a second tap that takes it back.
ChatChipFlowRow {
palette.forEach { emoji ->
ReactionChipView(
chip = ReactionChip(emoji, 1, false),
onClick = {
onReact(emoji)
onDismiss()
},
onLongClick = {},
)
}
}
SectionDivider()
}
TileRow {
if (isLive) {
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.cordn_action_reply)) {
onReply()
onDismiss()
}
}
// Pinning is any member's (spec/01.md §5.1), so it is offered on every
// message rather than only on your own.
ActionTile(
MaterialSymbols.PushPin,
stringRes(if (isPinned) R.string.cordn_action_unpin else R.string.cordn_action_pin),
) {
onTogglePin()
onDismiss()
}
// Edit and delete are author-only, and the manager refuses them for
// anyone else. Hiding them here is the same rule, stated where it stops
// being a surprise.
if (isMine && isLive) {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.cordn_action_edit)) {
onEdit()
onDismiss()
}
ActionTile(MaterialSymbols.Delete, stringRes(R.string.cordn_action_delete), isDestructive = true) {
onDelete()
onDismiss()
}
}
}
}
}
}
/**
* Where [message] sits inside a run of consecutive bubbles by the same sender, in the
* shared [ChatGroupPosition] vocabulary — so a cordn burst gets the same squared-off
* corners and tightened spacing as a DM burst, not just a hidden author line.
*
* The feed is reverse-laid-out: [newer] is the message rendered below, [older] above.
*/
internal fun cordnGroupPositionFor(
newer: CordnDeliveredMessage?,
message: CordnDeliveredMessage,
older: CordnDeliveredMessage?,
): ChatGroupPosition {
val connectedAbove = older != null && groupsWith(message, older)
val connectedBelow = newer != null && groupsWith(newer, message)
return when {
connectedAbove && connectedBelow -> ChatGroupPosition.MIDDLE
connectedAbove -> ChatGroupPosition.BOTTOM
connectedBelow -> ChatGroupPosition.TOP
else -> ChatGroupPosition.SINGLE
}
}
/**
* Whether [newer] continues the run [older] started — same sender, close in time, same
* day. Time as well as sender, because a reply hours later to your own last message is a
* new thought, and joining it to the run reads as though the conversation never paused.
*
* The window is the shared [CHAT_GROUP_WINDOW_SECONDS], so a cordn burst and a DM burst
* break in the same place.
*/
private fun groupsWith(
newer: CordnDeliveredMessage,
older: CordnDeliveredMessage,
): Boolean {
if (newer.envelope.pubKey != older.envelope.pubKey) return false
if (abs(newer.envelope.createdAt - older.envelope.createdAt) > CHAT_GROUP_WINDOW_SECONDS) return false
// A day separator between the two breaks the run, exactly as a date divisor does in
// the DM feed.
return newer.sameDayAs(older)
}
/** Whether both fall on the same local calendar day. A null [older] is a new day. */
internal fun CordnDeliveredMessage.sameDayAs(older: CordnDeliveredMessage?): Boolean {
if (older == null) return false
return localDayOf(envelope.createdAt) == localDayOf(older.envelope.createdAt)
}
private fun localDayOf(epochSeconds: Long): LocalDate = Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
/**
* The day a run of messages belongs to.
*
* Without one, a conversation is an undivided column and "yesterday evening" and "this
* morning" sit flush against each other.
*/
@Composable
internal fun DaySeparator(createdAt: Long) {
val day = remember(createdAt) { localDayOf(createdAt) }
val today = remember { LocalDate.now(ZoneId.systemDefault()) }
val label =
when (day) {
today -> stringRes(Res.string.today)
today.minusDays(1) -> stringRes(R.string.cordn_chat_yesterday)
// Year included only when it is not this one: printing 2026 on every
// divider all year is noise.
else ->
day.format(
DateTimeFormatter.ofPattern(
if (day.year == today.year) "d MMM" else "d MMM yyyy",
),
)
}
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
HorizontalDivider(Modifier.weight(1f))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(Modifier.weight(1f))
}
}
@@ -487,7 +487,7 @@ private fun ChatOnlyRow(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TileRow(content: @Composable () -> Unit) {
internal fun TileRow(content: @Composable () -> Unit) {
FlowRow(
modifier =
Modifier
@@ -501,7 +501,7 @@ private fun TileRow(content: @Composable () -> Unit) {
}
@Composable
private fun ActionTile(
internal fun ActionTile(
symbol: MaterialSymbol,
label: String,
isDestructive: Boolean = false,
@@ -540,7 +540,7 @@ private fun ActionTile(
private const val SECTION_DIVIDER_ALPHA = 0.5f
@Composable
private fun SectionDivider() {
internal fun SectionDivider() {
HorizontalDivider(
thickness = DividerThickness,
color = MaterialTheme.colorScheme.placeholderText.copy(alpha = SECTION_DIVIDER_ALPHA),
@@ -83,7 +83,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Immutable
private data class ReactionChip(
internal data class ReactionChip(
val type: String,
val count: Int,
val includesMe: Boolean,
@@ -212,7 +212,25 @@ fun ChatReactionChips(
}
}
/**
* The strip of engagement chips that rides a chat bubble's bottom border. Shared so
* every chat surface lays its chips out identically — [ChatReactionChips] fills it from
* a [Note]'s reactions and zaps, cordn fills it from its own annotation fold.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun ChatChipFlowRow(content: @Composable () -> Unit) {
FlowRow(
// Inset from the bubble's edge so overlapping chips ride the border without
// poking past the bubble's rounded corners.
modifier = Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
content()
}
}
@Composable
private fun RenderChatReactionChips(
chips: ImmutableList<ReactionChip>,
@@ -225,13 +243,7 @@ private fun RenderChatReactionChips(
) {
if (chips.isEmpty() && zapAmount.isBlank() && minichatCount <= 0 && !isZapping) return
FlowRow(
// Inset from the bubble's edge so overlapping chips ride the border without
// poking past the bubble's rounded corners.
modifier = Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
ChatChipFlowRow {
if (zapAmount.isNotBlank()) {
ZapChip(zapAmount, onClick = onOpenDetails)
} else if (isZapping) {
@@ -290,7 +302,7 @@ private fun MinichatChip(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ReactionChipView(
internal fun ReactionChipView(
chip: ReactionChip,
onClick: () -> Unit,
onLongClick: () -> Unit,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.util.codePointAtKmp
import com.vitorpamplona.amethyst.commons.util.codePointCharCount
@@ -137,3 +139,19 @@ private fun isEmojiBase(cp: Int): Boolean =
cp in 0x3297..0x3299 || // circled ideographs
cp == 0x3030 || // wavy dash
cp == 0x303D // part alternation mark
// Jumbo sizes step down as the emoji count grows so up to three still fit a line.
private val JumboEmojiSingle = 50.sp
private val JumboEmojiPair = 40.sp
private val JumboEmojiTriple = 32.sp
/**
* How large to draw an emoji-only message of [jumboCount] emoji. Shared by every chat
* surface that renders one, so a jumbo message is the same size wherever it appears.
*/
fun jumboEmojiFontSize(jumboCount: Int): TextUnit =
when (jumboCount) {
1 -> JumboEmojiSingle
2 -> JumboEmojiPair
else -> JumboEmojiTriple
}
@@ -88,7 +88,7 @@ fun chatBubbleShapeFor(
}
/** Messages more than this far apart never group, even from the same author. */
private const val GROUP_WINDOW_SECONDS = 10 * 60L
internal const val CHAT_GROUP_WINDOW_SECONDS = 10 * 60L
/**
* Event kinds that don't render as regular bubbles (zaps, raids, clips) or that
@@ -120,7 +120,7 @@ private fun groupsWith(
val olderAuthor = older.author?.pubkeyHex ?: return false
if (newerAuthor != olderAuthor) return false
if (abs(newerEvent.createdAt - olderEvent.createdAt) > GROUP_WINDOW_SECONDS) return false
if (abs(newerEvent.createdAt - olderEvent.createdAt) > CHAT_GROUP_WINDOW_SECONDS) return false
// A subject header renders as a divisor above the newer message.
if (newerEvent.subject() != null) return false
@@ -26,7 +26,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
@@ -40,11 +39,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.types.appendMissingImetaUrls
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
// Jumbo sizes step down as the emoji count grows so up to three still fit a line.
private val JumboEmojiSingle = 50.sp
private val JumboEmojiPair = 40.sp
private val JumboEmojiTriple = 32.sp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize
@Composable
fun RenderRegularTextNote(
@@ -68,12 +63,7 @@ fun RenderRegularTextNote(
// them is transparent — see NormalChatNote).
Text(
text = eventContent.trim(),
fontSize =
when (jumboCount) {
1 -> JumboEmojiSingle
2 -> JumboEmojiPair
else -> JumboEmojiTriple
},
fontSize = jumboEmojiFontSize(jumboCount),
)
} else {
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cordn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.cordnGroupPositionFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.sameDayAs
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneId
/**
* Where a cordn bubble sits in a run of its sender's messages.
*
* The feed is reverse-laid-out, so the newer/older argument order is easy to invert and
* impossible to notice from the code alone — an inverted version still produces
* plausible-looking bubbles, just with the tail on the wrong end of every burst. These
* assert the ends of a run by name.
*/
class CordnMessageGroupingTest {
private val alice: HexKey = "aa".repeat(32)
private val bob: HexKey = "bb".repeat(32)
/** Local noon today, so a ±10 minute window never crosses midnight by accident. */
private val noon =
LocalDate
.now(ZoneId.systemDefault())
.atStartOfDay(ZoneId.systemDefault())
.plusHours(12)
.toEpochSecond()
private var cursor = 0L
private fun msg(
author: HexKey,
at: Long,
) = CordnDeliveredMessage(
envelope = CordnEnvelope.build(author, at, 9, emptyArray(), "hi"),
cursor = ++cursor,
)
@Test
fun `a lone message is SINGLE`() {
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, msg(alice, noon), null))
}
@Test
fun `a run of three from one sender is TOP MIDDLE BOTTOM oldest first`() {
val oldest = msg(alice, noon)
val middle = msg(alice, noon + 60)
val newest = msg(alice, noon + 120)
// The oldest of a run renders at the visual top of the burst and carries the
// author line; the newest carries the bubble tail.
assertEquals(ChatGroupPosition.TOP, cordnGroupPositionFor(middle, oldest, null))
assertEquals(ChatGroupPosition.MIDDLE, cordnGroupPositionFor(newest, middle, oldest))
assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, newest, middle))
}
@Test
fun `a different sender between them breaks the run`() {
val mine = msg(alice, noon)
val theirs = msg(bob, noon + 60)
val mineAgain = msg(alice, noon + 120)
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(theirs, mine, null))
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(mineAgain, theirs, mine))
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, mineAgain, theirs))
}
@Test
fun `the shared ten-minute window is what decides, not a cordn-local one`() {
val first = msg(alice, noon)
// Inside the window the run holds...
val nine = msg(alice, noon + 9 * 60)
assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, nine, first))
// ...and just past it, it breaks. A cordn-local 5-minute window (what this
// screen used before adopting the shared one) would already have broken at 9.
val eleven = msg(alice, noon + 11 * 60)
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, eleven, first))
}
@Test
fun `a run does not cross local midnight even inside the time window`() {
val midnight =
LocalDate
.now(ZoneId.systemDefault())
.atStartOfDay(ZoneId.systemDefault())
.toEpochSecond()
val lastNight = msg(alice, midnight - 120)
val thisMorning = msg(alice, midnight + 120)
// Four minutes apart, same sender — but a day separator lands between them, so
// joining them into one burst would draw the separator inside a sealed bubble run.
assertFalse(thisMorning.sameDayAs(lastNight))
assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, thisMorning, lastNight))
}
@Test
fun `sameDayAs treats a missing neighbour as a new day`() {
// Drives the day separator above the very first message in the list.
assertFalse(msg(alice, noon).sameDayAs(null))
assertTrue(msg(alice, noon).sameDayAs(msg(bob, noon + 3600)))
}
}