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

630 lines
22 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.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatPalette
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.*
/**
* Utility functions for ChatScreen UI components
* Extracted from ChatScreen.kt for better organization
*/
/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */
internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha
/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */
internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm"
/** Background opacity for a mention chip referring to somebody else. */
internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha
/**
* Mention token grammar shared by rendered messages and the composer.
*
* The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the
* same nickname. Keeping one regex prevents the composer from styling only `@name` while the
* rendered transcript styles the complete token.
*/
internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
/**
* Get RSSI-based color for signal strength visualization
*/
fun getRSSIColor(rssi: Int): Color {
return when {
rssi >= -50 -> Color(0xFF00FF00) // Bright green
rssi >= -60 -> Color(0xFF80FF00) // Green-yellow
rssi >= -70 -> Color(0xFFFFFF00) // Yellow
rssi >= -80 -> Color(0xFFFF8000) // Orange
else -> Color(0xFFFF4444) // Red
}
}
/**
* Build the sender label shown above the first message of a group.
*
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
* annotation for everyone except yourself.
*/
fun formatTextMessageSender(
message: BitchatMessage,
currentUserNickname: String,
myPeerID: String,
palette: BitchatPalette
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
val senderColor = if (isSelf) {
palette.accentOrange
} else {
colorForPeer(peerIdentityForMessage(message), palette)
}
val senderWeight = FontWeight.SemiBold
val (baseName, suffix) = splitSuffix(message.sender)
builder.pushStyle(
SpanStyle(
color = senderColor,
fontSize = ChatVisualTokens.SenderFontSize,
fontWeight = senderWeight
)
)
builder.append("@")
val nicknameStart = builder.length
builder.append(truncateNickname(baseName))
val nicknameEnd = builder.length
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = message.originalSender ?: message.sender,
start = nicknameStart,
end = nicknameEnd
)
}
builder.pop()
if (suffix.isNotEmpty()) {
builder.pushStyle(
SpanStyle(
color = senderColor.copy(alpha = SUFFIX_ALPHA),
fontSize = ChatVisualTokens.SenderFontSize,
fontWeight = FontWeight.Normal
)
)
builder.append(suffix)
builder.pop()
}
return builder.toAnnotatedString()
}
/**
* Build the compact timestamp and optional proof-of-work label.
*
* Used standalone by media rows; text messages get the same span appended inline to the end of
* their body via [appendBodyTimestamp].
*/
fun formatTextMessageMetadata(
message: BitchatMessage,
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault())
): AnnotatedString {
val builder = AnnotatedString.Builder()
builder.pushStyle(
SpanStyle(
color = Color.Gray.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 4).sp
)
)
builder.append(timeFormatter.format(message.timestamp))
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
builder.append("${bits}b")
}
builder.pop()
return builder.toAnnotatedString()
}
/**
* Append the timestamp (and optional PoW difficulty) directly after the message body so it
* trails the final words rather than occupying its own column.
*
* Deliberately carries no click annotation: the timestamp is decoration, and making it
* tappable would create dead zones inside the message body.
*/
private fun appendTimestampText(
builder: AnnotatedString.Builder,
message: BitchatMessage,
timeFormatter: SimpleDateFormat
) {
builder.append(" ")
builder.append(timeFormatter.format(message.timestamp))
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
builder.append("${bits}b")
}
}
private fun appendBodyTimestamp(
builder: AnnotatedString.Builder,
message: BitchatMessage,
palette: BitchatPalette,
timeFormatter: SimpleDateFormat,
) {
builder.pushStyle(
SpanStyle(
color = palette.textTertiary,
fontSize = ChatVisualTokens.SystemTimeFontSize,
fontWeight = FontWeight.Normal,
)
)
appendTimestampText(builder, message, timeFormatter)
builder.pop()
}
private fun appendMutedTimestamp(
builder: AnnotatedString.Builder,
message: BitchatMessage,
contentColor: Color,
timeFormatter: SimpleDateFormat,
) {
builder.pushStyle(
SpanStyle(
color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha),
fontSize = ChatVisualTokens.SystemTimeFontSize,
fontWeight = FontWeight.Normal,
)
)
appendTimestampText(builder, message, timeFormatter)
builder.pop()
}
/**
* Build the message body: neutral text with mention/URL/geohash accents, followed by an inline
* trailing timestamp.
*
* Body text is intentionally neutral rather than peer-colored. Colour is reserved for
* `@names`, which is what makes a busy channel scannable.
*/
fun formatTextMessageBody(
message: BitchatMessage,
currentUserNickname: String,
palette: BitchatPalette,
contentColor: Color,
linkColor: Color,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
includeTimestamp: Boolean = true
): AnnotatedString {
val builder = AnnotatedString.Builder()
appendIOSFormattedContent(
builder = builder,
content = message.content,
currentUserNickname = currentUserNickname,
palette = palette,
contentColor = contentColor,
linkColor = linkColor,
mentionPeerIdentities = mentionPeerIdentities,
)
if (includeTimestamp) {
appendBodyTimestamp(builder, message, palette, timeFormatter)
}
return builder.toAnnotatedString()
}
/**
* Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`.
*
* The `//` prefix reads as machine narration in a monospace context and is far quieter than
* the previous `* italic asterisk *` treatment, which competed with real messages.
*/
fun formatSystemMessage(
message: BitchatMessage,
contentColor: Color,
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault())
): AnnotatedString {
val builder = AnnotatedString.Builder()
builder.pushStyle(
SpanStyle(
color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha),
fontSize = ChatVisualTokens.SystemActionFontSize,
fontWeight = FontWeight.Medium,
)
)
builder.append("// ")
builder.append(message.content)
builder.pop()
appendMutedTimestamp(builder, message, contentColor, timeFormatter)
return builder.toAnnotatedString()
}
/**
* Header line for media (image / audio / file) rows.
*
* Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an
* inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on
* the same line as the name.
*/
fun formatMessageHeaderAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
myPeerID: String,
palette: BitchatPalette,
contentColor: Color,
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
includeSender: Boolean = true
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
if (message.sender == "system") {
return formatSystemMessage(message, contentColor, timeFormatter)
}
if (includeSender) {
val baseColor = if (isSelf) {
palette.accentOrange
} else {
colorForPeer(peerIdentityForMessage(message), palette)
}
val (baseName, suffix) = splitSuffix(message.sender)
builder.pushStyle(
SpanStyle(
color = baseColor,
fontSize = ChatVisualTokens.SenderFontSize,
fontWeight = FontWeight.SemiBold,
)
)
builder.append("@")
val nicknameStart = builder.length
builder.append(truncateNickname(baseName))
val nicknameEnd = builder.length
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = (message.originalSender ?: message.sender),
start = nicknameStart,
end = nicknameEnd
)
}
builder.pop()
if (suffix.isNotEmpty()) {
builder.pushStyle(
SpanStyle(
color = baseColor.copy(alpha = SUFFIX_ALPHA),
fontSize = ChatVisualTokens.SenderFontSize,
fontWeight = FontWeight.Normal,
)
)
builder.append(suffix)
builder.pop()
}
}
appendMutedTimestamp(builder, message, contentColor, timeFormatter)
return builder.toAnnotatedString()
}
/**
* Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly)
*/
fun splitSuffix(name: String): Pair<String, String> {
if (name.length < 5) return Pair(name, "")
val suffix = name.takeLast(5)
if (suffix.startsWith("#") && suffix.drop(1).all {
it.isDigit() || it.lowercaseChar() in 'a'..'f'
}) {
val base = name.dropLast(5)
return Pair(base, suffix)
}
return Pair(name, "")
}
/**
* Build a case-insensitive mention-token lookup from canonical peer identities.
*
* Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when
* it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention
* as the wrong person.
*/
internal fun buildMentionPeerIdentityMap(
messages: List<BitchatMessage>,
knownPeers: List<Pair<String, PeerIdentity>> = emptyList(),
): Map<String, PeerIdentity> {
val candidates = linkedMapOf<String, MutableSet<PeerIdentity>>()
fun add(displayName: String, identity: PeerIdentity) {
val normalizedName = displayName.trim().removePrefix("@")
if (normalizedName.isEmpty()) return
val (baseName, suffix) = splitSuffix(normalizedName)
val exactKey = normalizedName.lowercase(Locale.ROOT)
candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity)
if (suffix.isNotEmpty()) {
val baseKey = baseName.lowercase(Locale.ROOT)
candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity)
}
}
messages
.asSequence()
.filterNot { it.sender == "system" }
.forEach { add(it.sender, peerIdentityForMessage(it)) }
knownPeers.forEach { (displayName, identity) -> add(displayName, identity) }
return candidates.mapNotNull { (token, identities) ->
identities.singleOrNull()?.let { token to it }
}.toMap()
}
internal fun resolveMentionPeerIdentity(
mention: String,
mentionPeerIdentities: Map<String, PeerIdentity>,
): PeerIdentity? {
val mentionWithoutAt = mention.trim().removePrefix("@")
val baseName = splitSuffix(mentionWithoutAt).first
return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)]
?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)]
}
/**
* Resolve the deterministic color for a mention on every surface that displays one.
*
* Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is
* unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID.
*/
internal fun colorForMention(
mention: String,
mentionPeerIdentities: Map<String, PeerIdentity>,
palette: BitchatPalette,
): Color {
val mentionWithoutAt = mention.trim().removePrefix("@")
val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities)
?: PeerIdentity.nickname(mentionWithoutAt)
return colorForPeer(identity, palette)
}
/**
* A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
* may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
* `anon1234`, `anonymous`, and `anonracer` are real announced usernames.
*/
internal fun isUnannouncedNickname(displayName: String): Boolean {
val base = splitSuffix(displayName.trim()).first
return base.equals("anon", ignoreCase = true)
}
/**
* iOS-style content formatting with proper hashtag and mention handling.
*
* Redesign notes:
* - Plain text renders in Material `onSurface`; colour is reserved for `@mentions`,
* links and geohashes.
* - Mentions get a tinted background chip so they read as a distinct token inside a sentence.
* The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks
* the same everywhere she is referenced.
* - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire
* paragraphs was the single biggest source of visual noise in the old layout; the mention
* chip carries that emphasis instead.
*/
private fun appendIOSFormattedContent(
builder: AnnotatedString.Builder,
content: String,
currentUserNickname: String,
palette: BitchatPalette,
contentColor: Color,
linkColor: Color,
mentionPeerIdentities: Map<String, PeerIdentity>,
) {
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
val hashtagMatches = hashtagPattern.findAll(content).toList()
val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList()
// Combine and sort matches, but exclude hashtags that overlap with mentions
val mentionRanges = mentionMatches.map { it.range }
fun overlapsMention(range: IntRange): Boolean {
return mentionRanges.any { mentionRange ->
range.first < mentionRange.last && range.last > mentionRange.first
}
}
val allMatches = mutableListOf<Pair<IntRange, String>>()
// Add hashtag matches that don't overlap with mentions
for (match in hashtagMatches) {
if (!overlapsMention(match.range)) {
allMatches.add(match.range to "hashtag")
}
}
// Add all mention matches
for (match in mentionMatches) {
allMatches.add(match.range to "mention")
}
// Add standalone geohash matches (e.g., "#9q") that are not part of another word
// We use MessageSpecialParser to find exact ranges; then merge with existing ranges avoiding overlaps
val geoMatches = MessageSpecialParser.findStandaloneGeohashes(content)
for (gm in geoMatches) {
val range = gm.start until gm.endExclusive
if (!overlapsMention(range)) {
allMatches.add(range to "geohash")
}
}
// Add URL matches (http/https/www/bare domains). Exclude overlaps with mentions.
val urlMatches = MessageSpecialParser.findUrls(content)
for (um in urlMatches) {
val range = um.start until um.endExclusive
if (!overlapsMention(range)) {
allMatches.add(range to "url")
}
}
// Remove generic hashtag matches that overlap with detected geohash ranges to avoid duplicate rendering
fun rangesOverlap(a: IntRange, b: IntRange): Boolean {
return a.first < b.last && a.last > b.first
}
val urlRanges = allMatches.filter { it.second == "url" }.map { it.first }
val geoRanges = allMatches.filter { it.second == "geohash" }.map { it.first }
if (geoRanges.isNotEmpty() || urlRanges.isNotEmpty()) {
val iterator = allMatches.listIterator()
while (iterator.hasNext()) {
val (range, type) = iterator.next()
// Remove generic hashtags that overlap with geohashes or URLs, and geohashes that overlap with URLs
val overlapsGeo = geoRanges.any { rangesOverlap(range, it) }
val overlapsUrl = urlRanges.any { rangesOverlap(range, it) }
if ((type == "hashtag" && (overlapsGeo || overlapsUrl)) || (type == "geohash" && overlapsUrl)) iterator.remove()
}
}
allMatches.sortBy { it.first.first }
val plainStyle = SpanStyle(
color = contentColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = FontWeight.Normal
)
val linkStyle = SpanStyle(
color = linkColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = FontWeight.Normal,
textDecoration = TextDecoration.Underline
)
var lastEnd = 0
for ((range, type) in allMatches) {
// Add text before match
if (lastEnd < range.first) {
val beforeText = content.substring(lastEnd, range.first)
if (beforeText.isNotEmpty()) {
builder.pushStyle(plainStyle)
builder.append(beforeText)
builder.pop()
}
}
// Add styled match
val matchText = content.substring(range.first, range.last + 1)
when (type) {
"mention" -> {
// iOS-style mention with hashtag suffix support
val mentionWithoutAt = matchText.removePrefix("@")
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
// Mentions targeting you are the one thing worth shouting about.
val isMentionToMe = mBase == currentUserNickname
val mentionColor = if (isMentionToMe) {
palette.accentOrange
} else {
colorForMention(
mention = mentionWithoutAt,
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
)
}
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA
val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold
// A single outer span carrying the background makes the chip render as one
// continuous rectangle. Pushing the background per-token would leave hairline
// seams between "@", the name and the "#abcd" suffix.
builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha)))
builder.pushStyle(SpanStyle(
color = mentionColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = mentionWeight
))
builder.append("@")
builder.append(truncateNickname(mBase))
builder.pop()
// Hashtag suffix in lighter color
if (mSuffix.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = mentionWeight
))
builder.append(mSuffix)
builder.pop()
}
builder.pop() // background chip
}
"hashtag" -> {
// Render general hashtags like normal content
builder.pushStyle(plainStyle)
builder.append(matchText)
builder.pop()
}
else -> {
if (type == "geohash") {
// Style geohash as a link and add click annotation
builder.pushStyle(linkStyle)
val start = builder.length
builder.append(matchText)
val end = builder.length
val geohash = matchText.removePrefix("#").lowercase()
builder.addStringAnnotation(
tag = "geohash_click",
annotation = geohash,
start = start,
end = end
)
builder.pop()
} else if (type == "url") {
// Style URL as a link and add click annotation with the raw text
builder.pushStyle(linkStyle)
val start = builder.length
builder.append(matchText)
val end = builder.length
builder.addStringAnnotation(
tag = "url_click",
annotation = matchText,
start = start,
end = end
)
builder.pop()
} else {
// Fallback: treat as normal text
builder.pushStyle(plainStyle)
builder.append(matchText)
builder.pop()
}
}
}
lastEnd = range.last + 1
}
// Add remaining text
if (lastEnd < content.length) {
val remainingText = content.substring(lastEnd)
builder.pushStyle(plainStyle)
builder.append(remainingText)
builder.pop()
}
}