mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge remote-tracking branch 'upstream/claude/kind-keller-itzy63' into test/kind-keller
This commit is contained in:
+48
-350
@@ -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,
|
||||
|
||||
+602
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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),
|
||||
|
||||
+21
-9
@@ -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,
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-12
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user