diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index 39041c0154..8197bfcaf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -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(null) } var editing by remember { mutableStateOf(null) } - var acting by remember { mutableStateOf(null) } var attaching by remember { mutableStateOf(false) } var attachError by remember { mutableStateOf(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>, - 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt new file mode 100644 index 0000000000..2cb1a02121 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt @@ -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>, + 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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 59a5c2f773..6c710ca708 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index e9c688188c..eb2857c4b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -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, @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt index 9e6934ca77..84d66d72b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt @@ -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 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt index 2f7c439a38..d942a54dd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index 7252ebb663..2df1d00456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -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 } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt new file mode 100644 index 0000000000..8a6066509a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt @@ -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))) + } +}