callebtc 719b3d1895 ui: mute theme-aware peer colors for contrast
Extract PeerColorStyle so each palette owns saturation/value, keeping
hues stable while dark mode stays bright-but-muted and light mode avoids
neon labels. New themes only need to supply their own style.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:06:46 +02:00

566 lines
21 KiB
Kotlin

package com.bitchat.android.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.DarkBitchatColorScheme
import com.bitchat.android.ui.theme.DarkBitchatPalette
import com.bitchat.android.ui.theme.LightBitchatColorScheme
import com.bitchat.android.ui.theme.LightBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.PeerColorStyle
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Runs under Robolectric because URL detection in message bodies goes through
* `android.util.Patterns.WEB_URL`, which is null on a bare JVM.
*/
@RunWith(RobolectricTestRunner::class)
class ChatUIUtilsTest {
private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
private val palette = DarkBitchatPalette
private val colorScheme = DarkBitchatColorScheme
private fun message(
content: String,
sender: String = "alice",
powDifficulty: Int? = null,
) = BitchatMessage(
sender = sender,
content = content,
timestamp = Date(0),
powDifficulty = powDifficulty,
)
// MARK: - Timestamp metadata
@Test
fun `text message metadata separates PoW badge with one space`() {
assertEquals(
"00:00 ⛨12b",
formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text,
)
}
@Test
fun `text message metadata omits non-positive PoW difficulty`() {
assertEquals(
"00:00",
formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text,
)
}
// MARK: - Body with inline trailing timestamp
@Test
fun `body appends timestamp inline after the message text`() {
val body = formatTextMessageBody(
message = message("hello there"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
)
assertEquals("hello there 00:00", body.text)
val timestamp = body.spanStyles.first {
body.text.substring(it.start, it.end) == " 00:00"
}.item
assertEquals(10.sp, timestamp.fontSize)
assertEquals(FontWeight.Normal, timestamp.fontWeight)
assertEquals(palette.textTertiary, timestamp.color)
}
@Test
fun `body appends PoW badge after the inline timestamp`() {
val body = formatTextMessageBody(
message = message("mined", powDifficulty = 8),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
)
assertEquals("mined 00:00 ⛨8b", body.text)
val timestampAndPow = body.spanStyles.first {
body.text.substring(it.start, it.end) == " 00:00 ⛨8b"
}.item
assertEquals(10.sp, timestampAndPow.fontSize)
assertEquals(palette.textTertiary, timestampAndPow.color)
}
@Test
fun `body can omit the timestamp for callers that render it separately`() {
val body = formatTextMessageBody(
message = message("hello there"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
assertEquals("hello there", body.text)
}
@Test
fun `body renders plain text in the neutral palette color, not terminal green`() {
val body = formatTextMessageBody(
message = message("plain words"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val textStyle = body.spanStyles.first { it.start == 0 }
assertEquals(colorScheme.onSurface, textStyle.item.color)
}
@Test
fun `chat text styles match the exported type scale`() {
assertEquals(14.sp, MessageBodyTextStyle.fontSize)
assertEquals(20.sp, MessageBodyTextStyle.lineHeight)
assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight)
assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily)
assertEquals(14.sp, MessageSenderTextStyle.fontSize)
assertEquals(16.sp, MessageSenderTextStyle.lineHeight)
assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight)
}
@Test
fun `body does not bold plain text for the sender's own messages`() {
// Regression guard: the old renderer bolded the entire body when the message was yours,
// and again when you were mentioned, which is what made busy channels unreadable.
val body = formatTextMessageBody(
message = message("my own words", sender = "bob"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
assertTrue(
"no span should force a bold weight",
body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true }
)
}
// MARK: - Mention chips
private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) =
body.spanStyles.filter { it.item.background.isSpecified() }
private fun androidx.compose.ui.graphics.Color.isSpecified() =
this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f
@Test
fun `mention renders as a single contiguous background chip`() {
val body = formatTextMessageBody(
message = message("hey @carol#04af what's up"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val chips = mentionChipSpans(body)
assertEquals("expected exactly one chip", 1, chips.size)
// The chip must cover "@carol#04af" as one run so it paints without seams.
val chip = chips.single()
assertEquals("@carol#04af", body.text.substring(chip.start, chip.end))
}
@Test
fun `mention targeting the current user uses the orange accent`() {
val body = formatTextMessageBody(
message = message("ping @bob now"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val chip = mentionChipSpans(body).single()
assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background)
assertEquals(0.2f, chip.item.background.alpha)
val nameStyle: SpanStyle = body.spanStyles
.first { it.start == chip.start && it.item.color == palette.accentOrange }
.item
assertEquals(palette.accentOrange, nameStyle.color)
}
@Test
fun `mention of another user is tinted by that user's own peer color`() {
val pubkey = "0123456789abcdef".repeat(4)
val identity = PeerIdentity.nostr(pubkey)
val mentionPeerIdentities = buildMentionPeerIdentityMap(
messages = listOf(
BitchatMessage(
sender = "carol#04af",
content = "hello",
timestamp = Date(0),
senderPeerID = "nostr:${pubkey.take(8)}",
senderNostrPubkey = pubkey,
)
)
)
val body = formatTextMessageBody(
message = message("cc @carol#04af"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val expected = colorForPeer(identity, palette)
val chip = mentionChipSpans(body).single()
assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background)
assertTrue(body.spanStyles.any { it.item.color == expected })
}
@Test
fun `composer colors nickname and hash suffix from the mentioned peer identity`() {
val pubkey = "0123456789abcdef".repeat(4)
val identity = PeerIdentity.nostr(pubkey)
val token = "@carol#04af"
val input = "ping $token now"
val transformed = MentionVisualTransformation(
mentionPeerIdentities = mapOf("carol#04af" to identity),
palette = palette,
).filter(AnnotatedString(input)).text
val expectedColor = colorForPeer(identity, palette)
val tokenStart = input.indexOf(token)
val suffixStart = input.indexOf("#04af")
val tokenEnd = tokenStart + token.length
assertEquals(input, transformed.text)
assertTrue(transformed.spanStyles.any {
it.start == tokenStart &&
it.end == tokenEnd &&
it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA)
})
assertTrue(transformed.spanStyles.any {
it.start == tokenStart &&
it.end == suffixStart &&
it.item.color == expectedColor
})
assertTrue(transformed.spanStyles.any {
it.start == suffixStart &&
it.end == tokenEnd &&
it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA)
})
}
@Test
fun `ambiguous base nickname is not assigned to the wrong peer`() {
val firstIdentity = PeerIdentity.nostr("11111111".repeat(8))
val secondIdentity = PeerIdentity.nostr("22222222".repeat(8))
val identities = buildMentionPeerIdentityMap(
messages = emptyList(),
knownPeers = listOf(
"alice#1111" to firstIdentity,
"alice#2222" to secondIdentity,
)
)
assertEquals(firstIdentity, identities["alice#1111"])
assertEquals(secondIdentity, identities["alice#2222"])
assertFalse(identities.containsKey("alice"))
assertEquals(
firstIdentity,
resolveMentionPeerIdentity("@alice#1111", identities)
)
assertEquals(null, resolveMentionPeerIdentity("@alice", identities))
}
@Test
fun `message without mentions has no background chips`() {
val body = formatTextMessageBody(
message = message("no mentions in here"),
currentUserNickname = "bob",
palette = palette,
contentColor = colorScheme.onSurface,
linkColor = colorScheme.secondary,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
assertTrue(mentionChipSpans(body).isEmpty())
}
// MARK: - System / action messages
@Test
fun `system message uses a double-slash prefix and no brackets`() {
val text = formatSystemMessage(
message = message("Tor started. Routing all chats via Tor", sender = "system"),
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
).text
assertEquals("// Tor started. Routing all chats via Tor 00:00", text)
}
@Test
fun `system message is not italic`() {
// The old treatment was `* italic asterisks *`, which competed visually with real
// messages despite being lower-priority narration.
val annotated = formatSystemMessage(
message = message("tor restarting", sender = "system"),
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
)
assertTrue(annotated.spanStyles.all { it.item.fontStyle == null })
}
@Test
fun `system action and timestamp use their exported weights sizes and opacity`() {
val annotated = formatSystemMessage(
message = message("tor restarting", sender = "system"),
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
)
val action = annotated.spanStyles.first {
annotated.text.substring(it.start, it.end) == "// tor restarting"
}.item
val time = annotated.spanStyles.first {
annotated.text.substring(it.start, it.end) == " 00:00"
}.item
assertEquals(12.sp, action.fontSize)
assertEquals(FontWeight.Medium, action.fontWeight)
assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color)
assertEquals(10.sp, time.fontSize)
assertEquals(FontWeight.Normal, time.fontWeight)
assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color)
}
// MARK: - Sender label
@Test
fun `sender label drops angle brackets and dims the hash suffix`() {
val sender = formatTextMessageSender(
message = message("hi", sender = "carol#04af"),
currentUserNickname = "bob",
myPeerID = "peer-me",
palette = palette,
)
assertEquals("@carol#04af", sender.text)
val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }
assertNotNull(suffixSpan.item.color)
assertEquals(14.sp, nameSpan.item.fontSize)
assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight)
assertEquals(14.sp, suffixSpan.item.fontSize)
assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight)
assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha)
assertTrue(
"suffix must be dimmer than the name",
suffixSpan.item.color.alpha < nameSpan.item.color.alpha
)
}
@Test
fun `sender label annotates the nickname for others but not for yourself`() {
val other = formatTextMessageSender(
message = message("hi", sender = "carol#04af"),
currentUserNickname = "bob",
myPeerID = "peer-me",
palette = palette,
)
assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size)
val mine = formatTextMessageSender(
message = message("hi", sender = "bob"),
currentUserNickname = "bob",
myPeerID = "peer-me",
palette = palette,
)
assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty())
}
// MARK: - Peer colors
@Test
fun `peer identity factories normalize stable IDs without resolving UI colors`() {
assertEquals(
PeerIdentity.mesh("abcdef"),
PeerIdentity.mesh("ABCDEF")
)
assertEquals(
PeerIdentity.nostr("abcdef"),
PeerIdentity.nostr("ABCDEF")
)
assertEquals(
PeerIdentity.nostr("abcdef"),
PeerIdentity.nostr("nostr:nostr_ABCDEF")
)
assertEquals(
"nostr:nostr:abcdef01",
PeerIdentity.nostr("ABCDEF0123456789").stableKey
)
assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey)
}
@Test
fun `peer color hue is stable across light and dark, only chroma differs`() {
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per
// theme so dark mode stays muted-but-bright and light mode stays deep-but-readable.
val identity = PeerIdentity.mesh("abc")
val dark = colorForPeer(identity, DarkBitchatPalette)
val light = colorForPeer(identity, LightBitchatPalette)
val darkHsv = FloatArray(3)
val lightHsv = FloatArray(3)
rgbToHsv(dark.red, dark.green, dark.blue, darkHsv)
rgbToHsv(light.red, light.green, light.blue, lightHsv)
assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01)
assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01)
assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01)
assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01)
// Dark theme: muted chroma, never dark (readable on near-black).
assertTrue(darkHsv[1] < 0.75f)
assertTrue(darkHsv[2] >= 0.75f)
// Light theme: avoid neon / near-white peer labels.
assertTrue(lightHsv[1] < 0.85f)
assertTrue(lightHsv[2] <= 0.55f)
}
@Test
fun `peer color avoids the orange hue reserved for self`() {
// Sweep a range of seeds; none may land within the reserved orange band.
repeat(500) { i ->
val color = colorForPeer(
PeerIdentity.mesh("seed$i"),
DarkBitchatPalette
)
val hsv = FloatArray(3)
rgbToHsv(color.red, color.green, color.blue, hsv)
val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f)
assertTrue(
"seed$i resolved to ${hsv[0]}°, inside the reserved orange band",
distanceFromOrange >= 17f || hsv[1] < 0.01f
)
}
}
@Test
fun `material owns standard text while Bitchat palette owns peer chroma`() {
assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
assertTrue(
LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors
)
}
@Test
fun `geohash chat and people sheet resolve the same full Nostr identity`() {
val pubkey = "ABCDEF0123456789".repeat(4)
val peopleIdentity = PeerIdentity.nostr(pubkey)
val chatIdentity = peerIdentityForMessage(
BitchatMessage(
sender = "alice#1234",
content = "hello",
timestamp = Date(0),
senderPeerID = "nostr:${pubkey.take(8)}",
senderNostrPubkey = pubkey,
)
)
assertEquals(peopleIdentity, chatIdentity)
assertEquals(
colorForPeer(peopleIdentity, palette),
colorForPeer(chatIdentity, palette)
)
}
@Test
fun `mesh chat and people sheet resolve the same peer identity`() {
val peerID = "ABCDEF0123456789"
val peopleIdentity = PeerIdentity.mesh(peerID)
val chatIdentity = peerIdentityForMessage(
BitchatMessage(
sender = "alice#1234",
content = "hello",
timestamp = Date(0),
senderPeerID = peerID,
)
)
assertEquals(peopleIdentity, chatIdentity)
}
@Test
fun `full Nostr identity wins over a truncated routing alias`() {
val pubkey = "0123456789ABCDEF".repeat(4)
val identity = peerIdentityForMessage(
BitchatMessage(
sender = "alice",
content = "hello",
timestamp = Date(0),
senderPeerID = "nostr_${pubkey.take(16)}",
senderNostrPubkey = pubkey,
)
)
assertEquals(PeerIdentity.nostr(pubkey), identity)
}
private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) {
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
out[0] = when {
delta == 0f -> 0f
max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}
out[1] = if (max == 0f) 0f else delta / max
out[2] = max
}
}