mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
* 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
630 lines
22 KiB
Kotlin
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()
|
|
}
|
|
}
|