callebtc 7025009788
ui: complete the redesign — palette, top bar, composer, About, and a motion pass (#774)
* first pass

* pass 2

* cleanup

* capitalization

* strings

* input bar fixes

* fixes

* notes

* nice

* nicer

* lists

* cleanup

* button

* fixes

* animations

* Fix layout jumpiness in chat and geohash people list

Three separate causes of things moving when they should not:

- Chat lurched whenever a bottom sheet closed. Placement animation is meant
  to soften insertions and removals, but any relayout moves every item --
  a sheet's text field opening the keyboard changes the chat's IME inset,
  and closing it changes it back. Placement animation is now armed only
  briefly around a real change to the message list, so items otherwise
  track the viewport exactly.

- Anon list changed height as participants churned. Rows sized to their
  content, so any reorder could change the card's height; and the card
  sized to the live anon count, which moves constantly in a busy geohash.
  Rows now have an exact height, and a trimmed anon card reserves the full
  capped height regardless of how many are present beyond the cap.

- Anons are now their own trailing section rather than a tail on each of
  "on location" and "teleported in", which had pushed the few recognisable
  names out of view twice over. Self is never grouped as an anon.

Adds 7 tests covering the sectioning and the fixed-length behaviour.

* Group geohash people as People and Anon

Replaces the "on location" / "teleported in" / "anonymous" split with two
sections: peers who announced a nickname, then the anons.

Teleport state was never worth a section of its own -- every row already
carries it as a distinct glyph -- and splitting on it fragmented the short
list people actually read, in a channel where most participants are
anonymous anyway.

Self stays in the People section even when unnamed.

* Key message list state per conversation

Switching channels reused every piece of state in MessagesList, because none
of it was keyed on which conversation was being shown:

- The LazyListState carried the previous channel's scroll offset, so the new
  channel opened at a stale position and then corrected itself.
- hasScrolledToInitialPosition and followIncomingMessages carried over, so a
  channel entered after scrolling up in another one did not land on its
  newest message at all.
- The arrival tracker had never seen the incoming channel's ids, so a
  backlog of six or fewer messages was treated as six simultaneous arrivals
  and each one slid in.
- previousMessageCount carried over, arming placement animation for the
  relayout that the switch itself caused.

All of it is now keyed on a conversationKey derived the same way
displayMessages is. The tracker also detects a list sharing no ids with the
previous one and adopts it silently, which covers /clear and any caller that
does not supply a distinct key.

Adds 4 tests for wholesale replacement, including the case that the burst
cap cannot catch on its own.

* fix location channel layout

* icon

* location sheet

* move location error

* fix location channel lifecycle bug

* remove empty lable

* geist mono

* timestamp no seconds

* new icons

* icons

* cleanup

* mentions

* fix mentions

* grouping of geohash channel list

* colors

* fix mention colors

* Bring private and group chat headers up to the main header's layout

Both conversation headers were built on TopAppBar with a centred title, a
back arrow on the left and everything else crowded into the title slot, at
14sp with 14dp icons. Moving between the timeline and a conversation visibly
shifted the bar's height, insets and type.

Introduces ConversationHeader, built from the main header's own tokens rather
than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph
in a 44dp slot so it lands exactly where the brand mark does, same -6dp
optical nudge pulling the title toward it, same 17sp label.

- Drops the back button; the close action on the right is the way out.
  Leaving a channel outright already lives on its row in the network sheet,
  so it does not need a second home beside the exit.
- Leading glyph is the transport: globe over the internet, wifi/bluetooth/
  routed on the mesh, matching the main header's channel button.
- Actions are right-aligned and unweighted -- favourite, encryption state,
  close -- so a long title yields space to them instead of pushing them off
  screen.
- Private chat titles use the primary green like every other header label,
  rather than orange for Nostr-reachable peers.

Height and edge insets now belong to each header variant instead of the
ChatFloatingHeader wrapper, which was applying them a second time to the
channel header.

Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth,
wifi, routed, close, check, warning, sync, lock_open, envelope -- so the
headers and peer rows no longer mix Material glyphs into the set.

* color
2026-07-27 17:33:11 +02:00

559 lines
20 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.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 for
// the redesigned neutral message body.
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(1.0, darkHsv[1].toDouble(), 0.01)
assertEquals(1.0, darkHsv[2].toDouble(), 0.01)
assertEquals(0.85, lightHsv[1].toDouble(), 0.01)
assertEquals(0.45, lightHsv[2].toDouble(), 0.01)
}
@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.peerColorValue != DarkBitchatPalette.peerColorValue
)
}
@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
}
}