Merge remote-tracking branch 'upstream/claude/kind-keller-itzy63' into test/kind-keller

This commit is contained in:
Vitor Pamplona
2026-09-24 09:06:00 -04:00
8 changed files with 828 additions and 376 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
@@ -61,18 +58,15 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
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
@@ -88,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
@@ -108,16 +98,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* One cordn room.
@@ -175,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) }
@@ -255,13 +237,13 @@ private fun CordnGroupChat(
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
// One clock for every divider in the room, so they cannot disagree.
val today = rememberToday()
// Pinned messages sit above the conversation rather than inside it.
// A pin is a claim about a message's importance, not a message, and
// leaving it only in place means the thing someone pinned scrolls
// away exactly like everything else.
// One clock for every divider in the room, so they cannot disagree.
val today = rememberToday()
PinnedRibbon(annotations, scope) { message ->
trySend {
it.post(
@@ -288,28 +270,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()) } }
},
@@ -418,49 +433,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. */
@@ -518,56 +490,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,
@@ -598,230 +520,6 @@ private fun CordnChatTopBar(
)
}
/** How long a burst from one sender stays one burst. */
private const val GROUPING_WINDOW_SECONDS = 5 * 60
/** Upper bound on how long a stale "Today" can survive a clock correction. */
private const val TODAY_POLL_MS = 60_000L
/**
* 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()
/**
* Today, as a value that stops being today when it stops being today.
*
* [DaySeparator] used to hold `remember { LocalDate.now() }` of its own. That
* is a snapshot of the wall clock with nothing to invalidate it, and each
* separator keeps a separate one, so they can disagree: a separator composed
* before midnight goes on saying "Today" while the one for the new day says it
* too. Seen on the tablet -- one room, two "Today" dividers.
*
* Polling rather than a single sleep to the next midnight, because a device
* clock does not only advance: it is corrected, and the tablet this was found
* on jumped nine hours in one step. Re-assigning an equal [LocalDate] is not a
* change, so a quiet minute costs no recomposition.
*/
@Composable
private fun rememberToday(): LocalDate {
val zone = remember { ZoneId.systemDefault() }
return produceState(LocalDate.now(zone), zone) {
while (true) {
val now = ZonedDateTime.now(zone)
val untilMidnight = Duration.between(now, now.toLocalDate().plusDays(1).atStartOfDay(zone)).toMillis()
delay(untilMidnight.coerceIn(1_000L, TODAY_POLL_MS))
value = LocalDate.now(zone)
}
}.value
}
/**
* 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,
today: LocalDate,
) {
val day = remember(createdAt) { localDayOf(createdAt) }
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,
@@ -869,7 +567,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,
@@ -961,7 +659,7 @@ private suspend fun sendAttachment(
* user's.
*/
@Composable
private fun CordnAttachment(
internal fun CordnAttachment(
attachment: CordnMediaAttachment,
room: CordnGroupChatroom,
accountViewModel: AccountViewModel,
@@ -0,0 +1,602 @@
/*
* 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.produceState
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 kotlinx.coroutines.delay
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
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,
today: LocalDate,
) {
val day = remember(createdAt) { localDayOf(createdAt) }
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))
}
}
/** Upper bound on how long a stale "Today" can survive a clock correction. */
private const val TODAY_POLL_MS = 60_000L
/**
* Today, as a value that stops being today when it stops being today.
*
* [DaySeparator] used to hold `remember { LocalDate.now() }` of its own. That is a
* snapshot of the wall clock with nothing to invalidate it, and each separator keeps a
* separate one, so they can disagree: a separator composed before midnight goes on
* saying "Today" while the one for the new day says it too. Seen on the tablet — one
* room, two "Today" dividers.
*
* Polling rather than a single sleep to the next midnight, because a device clock does
* not only advance: it is corrected, and the tablet this was found on jumped nine hours
* in one step. Re-assigning an equal [LocalDate] is not a change, so a quiet minute
* costs no recomposition.
*/
@Composable
internal fun rememberToday(): LocalDate {
val zone = remember { ZoneId.systemDefault() }
return produceState(LocalDate.now(zone), zone) {
while (true) {
val now = ZonedDateTime.now(zone)
val untilMidnight = Duration.between(now, now.toLocalDate().plusDays(1).atStartOfDay(zone)).toMillis()
delay(untilMidnight.coerceIn(1_000L, TODAY_POLL_MS))
value = LocalDate.now(zone)
}
}.value
}
@@ -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)))
}
}