ui: refresh chat branding and text message layout (#767)

* Add BitChatBrandButton and BitChatIcon

Add a dedicated brand button component and custom vector icon to the UI library. Refactor the chat header to use this component, replacing the previous modifier-based multi-click implementation.

* **BitChatBrandButton**: A new Composable that encapsulates single and triple click detection logic using coroutine delays and tap counting.
* **BitChatIcon**: A custom pixel-style `ImageVector` representing the brand.
* **ChatHeader**: Updated to use the new brand button and included a visual separator (`/`) in the layout.
* **ModifierExt.kt**: Removed the `singleOrTripleClickable` extension as its functionality is now handled internally by the brand button component.

* ui: refactor text messages to two-row layout

Refactor the message list items to use a two-row layout for standard text messages, while preserving the legacy compact format for system messages.

*   **UI Formatting**: Added `formatTextMessageSender`, `formatTextMessageMetadata`, and `formatTextMessageBody` in `ChatUIUtils.kt` to separate the rendering of sender info, timestamps/PoW, and message content.
*   **Layout Change**: Replaced the single-block `Text` component with a `Column` containing a `Row` (Sender + Metadata) and a message body `Text` block for standard messages.
*   **Interaction**: Updated `pointerInput` and `detectTapGestures` to handle nickname clicks in the header row and geohash/URL clicks within the message body row.
*   **Styling**: Refined `appendIOSFormattedContent` to support a `contentColor` parameter and applied specific font weights and colors (e.g., orange for self-messages) to match the platform's design language.
*   **System Messages**: Explicitly kept messages where `sender == "system"` on the original compact layout.

* refactor(ui): extract annotated text pointer handling

* fix(ui): address chat redesign review feedback

* fix(ui): align PoW animation with text layout

* refactor(ui): extract message interaction helpers

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
Ruslan 2026-07-27 02:54:51 +04:00 committed by GitHub
parent 61588db474
commit 92d07b22fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 812 additions and 428 deletions

View File

@ -0,0 +1,69 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.bitchat.android.core.ui.icon.BitChatIcon
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
private val MultiClickThreshold = 300.milliseconds
@Composable
fun BitChatBrandButton(
onClick: () -> Unit,
onTripleClick: () -> Unit,
contentDescription: String,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.primary,
) {
var tapCount by remember { mutableIntStateOf(0) }
var resetJob by remember { mutableStateOf<Job?>(null) }
val coroutineScope = rememberCoroutineScope()
val currentOnClick by rememberUpdatedState(onClick)
val currentOnTripleClick by rememberUpdatedState(onTripleClick)
IconButton(
onClick = {
tapCount += 1
resetJob?.cancel()
if (tapCount == 3) {
tapCount = 0
resetJob = null
currentOnTripleClick()
} else {
resetJob = coroutineScope.launch {
delay(MultiClickThreshold)
if (tapCount == 1) {
currentOnClick()
}
tapCount = 0
resetJob = null
}
}
},
modifier = modifier,
) {
Icon(
imageVector = BitChatIcon,
contentDescription = contentDescription,
tint = tint,
modifier = Modifier.size(16.dp),
)
}
}

View File

@ -0,0 +1,96 @@
package com.bitchat.android.core.ui.component.text
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
internal data class ClickedAnnotation(
val tag: String,
val item: String,
)
internal fun findAnnotationAt(
text: AnnotatedString,
offset: Int,
annotationTags: List<String>,
): ClickedAnnotation? {
for (tag in annotationTags) {
text.getStringAnnotations(tag = tag, start = offset, end = offset)
.firstOrNull()
?.let { annotation ->
return ClickedAnnotation(tag = tag, item = annotation.item)
}
}
return null
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnnotatedClickableText(
text: AnnotatedString,
annotationTags: List<String>,
onAnnotationClick: (tag: String, item: String) -> Boolean,
modifier: Modifier = Modifier,
onLongPress: (() -> Unit)? = null,
color: Color = Color.Unspecified,
fontFamily: FontFamily? = null,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
style: TextStyle = LocalTextStyle.current,
) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick)
val currentOnLongPress by rememberUpdatedState(onLongPress)
Text(
text = text,
modifier = modifier.pointerInput(text, annotationTags, onLongPress != null) {
detectTapGestures(
onTap = { position ->
val offset = layoutResult
?.getOffsetForPosition(position)
?: return@detectTapGestures
var remainingTags = annotationTags
while (remainingTags.isNotEmpty()) {
val annotation = findAnnotationAt(
text = text,
offset = offset,
annotationTags = remainingTags,
) ?: break
if (currentOnAnnotationClick(annotation.tag, annotation.item)) {
return@detectTapGestures
}
remainingTags = remainingTags.drop(
remainingTags.indexOf(annotation.tag) + 1
)
}
},
onLongPress = currentOnLongPress?.let { callback ->
{ callback() }
},
)
},
color = color,
fontFamily = fontFamily,
softWrap = softWrap,
overflow = overflow,
style = style,
onTextLayout = { layoutResult = it },
)
}

View File

@ -0,0 +1,48 @@
package com.bitchat.android.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val BitChatIcon: ImageVector
get() {
_BitChatIcon?.let { return it }
return ImageVector.Builder(
name = "BitChatIcon",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 8f,
viewportHeight = 8f,
).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(2f, 0f)
lineTo(6f, 0f)
lineTo(6f, 1f)
lineTo(7f, 1f)
lineTo(7f, 2f)
lineTo(8f, 2f)
lineTo(8f, 5f)
lineTo(7f, 5f)
lineTo(7f, 6f)
lineTo(6f, 6f)
lineTo(6f, 8f)
lineTo(5f, 8f)
lineTo(5f, 7f)
lineTo(3f, 7f)
lineTo(3f, 6f)
lineTo(1f, 6f)
lineTo(1f, 5f)
lineTo(0f, 5f)
lineTo(0f, 2f)
lineTo(1f, 2f)
lineTo(1f, 1f)
lineTo(2f, 1f)
close()
}
}.build().also { _BitChatIcon = it }
}
private var _BitChatIcon: ImageVector? = null

View File

@ -1,57 +0,0 @@
package com.bitchat.android.core.ui.utils
import androidx.compose.foundation.clickable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
fun Modifier.singleOrTripleClickable(
onSingleClick: () -> Unit,
onTripleClick: () -> Unit,
clickTimeThreshold: Long = 300L
): Modifier = composed {
var tapCount by remember { mutableIntStateOf(0) }
var lastTapTime by remember { mutableLongStateOf(0L) }
var singleClickJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
val coroutineScope = rememberCoroutineScope()
this.clickable {
val currentTime = System.currentTimeMillis()
if (currentTime - lastTapTime < clickTimeThreshold) {
tapCount++
} else {
tapCount = 1
}
lastTapTime = currentTime
// Cancel any pending single click action
singleClickJob?.cancel()
singleClickJob = null
when (tapCount) {
1 -> {
// Wait to see if more taps come
singleClickJob = coroutineScope.launch {
delay(clickTimeThreshold)
if (tapCount == 1) {
onSingleClick()
}
}
}
3 -> {
// Triple click detected - execute immediately
onTripleClick()
tapCount = 0
}
}
// Reset after threshold if no triple click
if (tapCount > 3) {
tapCount = 0
}
}
}

View File

@ -26,10 +26,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
/** /**
* Header components for ChatScreen * Header components for ChatScreen
@ -356,16 +356,18 @@ private fun MainHeader(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( BitChatBrandButton(
text = stringResource(R.string.app_brand), onClick = onTitleClick,
style = MaterialTheme.typography.headlineSmall, onTripleClick = onTripleTitleClick,
color = colorScheme.primary, contentDescription = stringResource(R.string.cd_open_about),
modifier = Modifier.singleOrTripleClickable(
onSingleClick = onTitleClick,
onTripleClick = onTripleTitleClick
)
) )
Text(
text = "/",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary,
)
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
NicknameEditor( NicknameEditor(

View File

@ -3,13 +3,9 @@ package com.bitchat.android.ui
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.ui.graphics.vector.ImageVector
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.MeshService
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
@ -50,9 +46,7 @@ fun formatMessageAsAnnotatedString(
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
// Determine if this message was sent by self // Determine if this message was sent by self
val isSelf = message.senderPeerID == meshService.myPeerID || val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
if (message.sender != "system") { if (message.sender != "system") {
// Get base color for this peer (iOS-style color assignment) // Get base color for this peer (iOS-style color assignment)
@ -117,7 +111,14 @@ fun formatMessageAsAnnotatedString(
builder.pop() builder.pop()
// Message content with iOS-style hashtag and mention highlighting // Message content with iOS-style hashtag and mention highlighting
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark) appendIOSFormattedContent(
builder,
message.content,
message.mentions,
currentUserNickname,
baseColor,
isSelf,
)
// iOS-style timestamp at the END (smaller, grey) // iOS-style timestamp at the END (smaller, grey)
// Timestamp (and optional PoW badge) // Timestamp (and optional PoW badge)
@ -156,6 +157,108 @@ fun formatMessageAsAnnotatedString(
return builder.toAnnotatedString() return builder.toAnnotatedString()
} }
/**
* Build the sender label used by the two-row text-message layout.
*/
fun formatTextMessageSender(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark =
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
val (baseName, suffix) = splitSuffix(message.sender)
builder.pushStyle(
SpanStyle(
color = senderColor,
fontSize = BASE_FONT_SIZE.sp,
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 = 0.6f),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = senderWeight
)
)
builder.append(suffix)
builder.pop()
}
return builder.toAnnotatedString()
}
/**
* Build the compact timestamp and optional proof-of-work label.
*/
fun formatTextMessageMetadata(
message: BitchatMessage,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", 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()
}
/**
* Build only the message body while retaining mention, URL and geohash styling.
*/
fun formatTextMessageBody(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark =
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
appendIOSFormattedContent(
builder = builder,
content = message.content,
mentions = message.mentions,
currentUserNickname = currentUserNickname,
baseColor = accentColor,
isSelf = isSelf,
contentColor = colorScheme.onSurface
)
return builder.toAnnotatedString()
}
/** /**
* Build only the nickname + timestamp header line for a message, matching styles of normal messages. * Build only the nickname + timestamp header line for a message, matching styles of normal messages.
*/ */
@ -169,9 +272,7 @@ fun formatMessageHeaderAnnotatedString(
val builder = AnnotatedString.Builder() val builder = AnnotatedString.Builder()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val isSelf = message.senderPeerID == meshService.myPeerID || val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
if (message.sender != "system") { if (message.sender != "system") {
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
@ -338,7 +439,7 @@ private fun appendIOSFormattedContent(
currentUserNickname: String, currentUserNickname: String,
baseColor: Color, baseColor: Color,
isSelf: Boolean, isSelf: Boolean,
isDark: Boolean contentColor: Color = baseColor,
) { ) {
// iOS-style patterns: allow optional '#abcd' suffix in mentions // iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
@ -416,7 +517,7 @@ private fun appendIOSFormattedContent(
val beforeText = content.substring(lastEnd, range.first) val beforeText = content.substring(lastEnd, range.first)
if (beforeText.isNotEmpty()) { if (beforeText.isNotEmpty()) {
builder.pushStyle(SpanStyle( builder.pushStyle(SpanStyle(
color = baseColor, color = contentColor,
fontSize = BASE_FONT_SIZE.sp, fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
)) ))
@ -476,7 +577,7 @@ private fun appendIOSFormattedContent(
"hashtag" -> { "hashtag" -> {
// Render general hashtags like normal content // Render general hashtags like normal content
builder.pushStyle(SpanStyle( builder.pushStyle(SpanStyle(
color = baseColor, color = contentColor,
fontSize = BASE_FONT_SIZE.sp, fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
)) ))
@ -530,7 +631,7 @@ private fun appendIOSFormattedContent(
} else { } else {
// Fallback: treat as normal text // Fallback: treat as normal text
builder.pushStyle(SpanStyle( builder.pushStyle(SpanStyle(
color = baseColor, color = contentColor,
fontSize = BASE_FONT_SIZE.sp, fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
)) ))
@ -547,7 +648,7 @@ private fun appendIOSFormattedContent(
if (lastEnd < content.length) { if (lastEnd < content.length) {
val remainingText = content.substring(lastEnd) val remainingText = content.substring(lastEnd)
builder.pushStyle(SpanStyle( builder.pushStyle(SpanStyle(
color = baseColor, color = contentColor,
fontSize = BASE_FONT_SIZE.sp, fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
)) ))

View File

@ -1,16 +1,17 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import androidx.compose.material3.* import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import kotlin.random.Random import kotlin.random.Random
/** /**
@ -18,7 +19,6 @@ import kotlin.random.Random
*/ */
private enum class CharacterAnimationState { private enum class CharacterAnimationState {
ENCRYPTED, // Showing random encrypted characters ENCRYPTED, // Showing random encrypted characters
DECRYPTING, // Transitioning to final character
FINAL // Showing final decrypted character FINAL // Showing final decrypted character
} }
@ -69,205 +69,111 @@ object PoWMiningTracker {
} }
/** /**
* Enhanced message display that shows matrix animation during PoW mining * Shows the active PoW animation inside the same two-row layout used by static text messages.
* Formats message like a normal message but animates only the content portion
*/ */
@Composable @Composable
fun MessageWithMatrixAnimation( fun MessageWithMatrixAnimation(
message: com.bitchat.android.model.BitchatMessage, message: BitchatMessage,
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
currentUserNickname: String, currentUserNickname: String,
meshService: com.bitchat.android.mesh.MeshService, meshService: MeshService,
colorScheme: androidx.compose.material3.ColorScheme, colorScheme: ColorScheme,
timeFormatter: java.text.SimpleDateFormat, timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?, onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?, modifier: Modifier = Modifier,
modifier: Modifier = Modifier
) { ) {
val isAnimating = shouldAnimateMessage(message.id) AnimatedMessageDisplay(
message = message,
if (isAnimating) { currentUserNickname = currentUserNickname,
// During animation: Show formatted message with animated content meshService = meshService,
AnimatedMessageDisplay( colorScheme = colorScheme,
message = message, timeFormatter = timeFormatter,
currentUserNickname = currentUserNickname, onNicknameClick = onNicknameClick,
meshService = meshService, onMessageLongPress = onMessageLongPress,
colorScheme = colorScheme, modifier = modifier,
timeFormatter = timeFormatter, )
modifier = modifier
)
} else {
// After animation: Show complete normal message using existing formatter
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
Text(
text = annotatedText,
modifier = modifier,
fontFamily = FontFamily.Monospace,
softWrap = true
)
}
} }
/** /**
* Display message with proper formatting but animated content * Animates only the body content; sender, metadata, gestures, and spacing remain stable.
* Uses IDENTICAL layout structure as normal message for pixel-perfect alignment
*/ */
@Composable @Composable
private fun AnimatedMessageDisplay( private fun AnimatedMessageDisplay(
message: com.bitchat.android.model.BitchatMessage, message: BitchatMessage,
currentUserNickname: String, currentUserNickname: String,
meshService: com.bitchat.android.mesh.MeshService, meshService: MeshService,
colorScheme: androidx.compose.material3.ColorScheme, colorScheme: ColorScheme,
timeFormatter: java.text.SimpleDateFormat, timeFormatter: SimpleDateFormat,
modifier: Modifier = Modifier onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
) { ) {
// Get the animated content text var animatedContent by remember(message.id, message.content) {
var animatedContent by remember(message.content) { mutableStateOf(message.content) } mutableStateOf(message.content)
val isAnimating = shouldAnimateMessage(message.id) }
// Character-by-character animation state like the JavaScript version // Character-by-character animation state like the JavaScript version
var characterStates by remember(message.content) { var characterStates by remember(message.id, message.content) {
mutableStateOf(message.content.map { char -> mutableStateOf(message.content.map { char ->
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
}) })
} }
// Update animated content when animation state changes LaunchedEffect(message.id, message.content) {
LaunchedEffect(isAnimating, message.content) { if (message.content.isEmpty()) return@LaunchedEffect
if (isAnimating && message.content.isNotEmpty()) {
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
// Start character animations with staggered delays (like JS version) // Start character animations with staggered delays (like JS version).
message.content.forEachIndexed { index, targetChar -> message.content.forEachIndexed { index, targetChar ->
if (targetChar != ' ') { // Skip spaces if (targetChar != ' ') {
launch { launch {
delay(index * 50L) // Stagger start like JS version delay(index * 50L)
// Animate this character indefinitely in a loop while (true) {
while (true) { while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
// Animate with random characters val newContent = animatedContent.toCharArray()
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { if (index < newContent.size) {
// Generate random encrypted character for this position newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
val newContent = animatedContent.toCharArray() animatedContent = String(newContent)
if (index < newContent.size) { }
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
animatedContent = String(newContent) delay(100L)
}
if (Random.nextFloat() < 0.1f) {
delay(100L) // Change character every 100ms like JS val finalContent = animatedContent.toCharArray()
if (index < finalContent.size) {
// Random chance to reveal (10% like JS version) finalContent[index] = targetChar
if (Random.nextFloat() < 0.1f) { animatedContent = String(finalContent)
// Reveal the final character }
val finalContent = animatedContent.toCharArray()
if (index < finalContent.size) { val finalStates = characterStates.toMutableList()
finalContent[index] = targetChar finalStates[index] = CharacterAnimationState.FINAL
animatedContent = String(finalContent) characterStates = finalStates
} break
// Mark as revealed
val finalStates = characterStates.toMutableList()
finalStates[index] = CharacterAnimationState.FINAL
characterStates = finalStates
break
}
} }
// Keep revealed for 2 seconds, then fade back to encrypted (like JS)
delay(2000L)
// Reset back to encrypted for next cycle
val resetStates = characterStates.toMutableList()
resetStates[index] = CharacterAnimationState.ENCRYPTED
characterStates = resetStates
} }
delay(2000L)
val resetStates = characterStates.toMutableList()
resetStates[index] = CharacterAnimationState.ENCRYPTED
characterStates = resetStates
} }
} }
} }
} else {
// Not animating, show final content
animatedContent = message.content
characterStates = message.content.map { CharacterAnimationState.FINAL }
} }
} }
// Create a temporary message with animated content for formatting
val animatedMessage = message.copy(content = animatedContent)
// Use formatting function without timestamp during animation
val annotatedText = if (isAnimating) {
formatMessageAsAnnotatedStringWithoutTimestamp(
message = animatedMessage,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme
)
} else {
formatMessageAsAnnotatedString(
message = animatedMessage,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
}
// Use IDENTICAL Text composable structure as normal message
Text(
text = annotatedText,
modifier = modifier,
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = androidx.compose.ui.text.style.TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
)
)
}
TextMessageLayout(
/**
* Format message without timestamp and PoW badge for animation phase
* Identical to formatMessageAsAnnotatedString but excludes timestamp and PoW badge
*/
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.MeshService,
colorScheme: androidx.compose.material3.ColorScheme
): AnnotatedString {
// Get the full formatted text first
val timeFormatter = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
val fullText = formatMessageAsAnnotatedString(
message = message, message = message,
currentUserNickname = currentUserNickname, currentUserNickname = currentUserNickname,
meshService = meshService, meshService = meshService,
colorScheme = colorScheme, colorScheme = colorScheme,
timeFormatter = timeFormatter timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
bodyContent = animatedContent,
) )
// Find and remove the timestamp and PoW badge at the end
val text = fullText.text
val timestampPattern = """ \[\d{2}:\d{2}:\d{2}].*$""".toRegex() // Matches " [HH:mm:ss] 12b" or just " [HH:mm:ss]"
val match = timestampPattern.find(text)
return if (match != null) {
// Remove timestamp and PoW portion
val endIndex = match.range.first
AnnotatedString(
text = text.substring(0, endIndex),
spanStyles = fullText.spanStyles.filter { it.end <= endIndex },
paragraphStyles = fullText.paragraphStyles.filter { it.end <= endIndex }
)
} else {
fullText
}
} }

View File

@ -1,50 +1,57 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.draw.clip import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.* import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.* import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import android.content.Intent
import android.net.Uri
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.MeshService
import java.text.SimpleDateFormat
import java.util.*
import com.bitchat.android.ui.media.VoiceNotePlayer
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.R import com.bitchat.android.R
import androidx.compose.ui.res.stringResource import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.media.FileMessageItem
import java.text.SimpleDateFormat
import java.util.Locale
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer // VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
@ -266,23 +273,21 @@ fun MessageItem(
timeFormatter = timeFormatter timeFormatter = timeFormatter
) )
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) } AnnotatedClickableText(
Text(
text = headerText, text = headerText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface, color = colorScheme.onSurface,
modifier = Modifier.pointerInput(message.id) {
detectTapGestures(onTap = { pos ->
val layout = headerLayout ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(pos)
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
if (ann.isNotEmpty() && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(ann.first().item)
}
}, onLongPress = { onMessageLongPress?.invoke(message) })
},
onTextLayout = { headerLayout = it }
) )
// Try to load the file packet from the path // Try to load the file packet from the path
@ -354,18 +359,16 @@ fun MessageItem(
// Display message with matrix animation for content // Display message with matrix animation for content
MessageWithMatrixAnimation( MessageWithMatrixAnimation(
message = message, message = message,
messages = messages,
currentUserNickname = currentUserNickname, currentUserNickname = currentUserNickname,
meshService = meshService, meshService = meshService,
colorScheme = colorScheme, colorScheme = colorScheme,
timeFormatter = timeFormatter, timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick, onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress, onMessageLongPress = onMessageLongPress,
onImageClick = onImageClick,
modifier = modifier modifier = modifier
) )
} else { } else if (message.sender == "system") {
// Normal message display // Keep system messages on the compact legacy line.
val annotatedText = formatMessageAsAnnotatedString( val annotatedText = formatMessageAsAnnotatedString(
message = message, message = message,
currentUserNickname = currentUserNickname, currentUserNickname = currentUserNickname,
@ -373,80 +376,12 @@ fun MessageItem(
colorScheme = colorScheme, colorScheme = colorScheme,
timeFormatter = timeFormatter timeFormatter = timeFormatter
) )
// Check if this message was sent by self to avoid click interactions on own nickname
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text( Text(
text = annotatedText, text = annotatedText,
modifier = modifier.pointerInput(message) { modifier = modifier.pointerInput(message) {
detectTapGestures( detectTapGestures(
onTap = { position ->
val layout = textLayoutResult ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(position)
// Nickname click only when not self
if (!isSelf && onNicknameClick != null) {
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
start = offset,
end = offset
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(nickname)
return@detectTapGestures
}
}
// Geohash teleport (all messages)
val geohashAnnotations = annotatedText.getStringAnnotations(
tag = "geohash_click",
start = offset,
end = offset
)
if (geohashAnnotations.isNotEmpty()) {
val geohash = geohashAnnotations.first().item
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
context
)
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
locationManager.setTeleported(true)
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
// URL open (all messages)
val urlAnnotations = annotatedText.getStringAnnotations(
tag = "url_click",
start = offset,
end = offset
)
if (urlAnnotations.isNotEmpty()) {
val raw = urlAnnotations.first().item
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
},
onLongPress = { onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message) onMessageLongPress?.invoke(message)
@ -458,8 +393,129 @@ fun MessageItem(
overflow = TextOverflow.Visible, overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle( style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface color = colorScheme.onSurface
), )
onTextLayout = { result -> textLayoutResult = result } )
} else {
TextMessageLayout(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
)
}
}
@Composable
internal fun TextMessageLayout(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
bodyContent: String = message.content,
) {
val myPeerId = meshService.myPeerID
val displayMessage = remember(message, bodyContent) {
if (bodyContent == message.content) message else message.copy(content = bodyContent)
}
val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
formatTextMessageSender(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
)
}
val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
formatTextMessageMetadata(
message = message,
timeFormatter = timeFormatter,
)
}
val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
formatTextMessageBody(
message = displayMessage,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val handleLongPress: () -> Unit = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = handleLongPress,
modifier = Modifier.weight(1f),
fontFamily = FontFamily.Monospace,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
AnnotatedClickableText(
text = metadataText,
annotationTags = emptyList(),
onAnnotationClick = { _, _ -> false },
onLongPress = handleLongPress,
fontFamily = FontFamily.Monospace,
softWrap = false,
)
}
AnnotatedClickableText(
text = bodyText,
annotationTags = listOf("geohash_click", "url_click"),
onAnnotationClick = { tag, item ->
when (tag) {
"geohash_click" -> {
navigateToGeohash(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
"url_click" -> {
openMessageUrl(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
else -> false
}
},
onLongPress = handleLongPress,
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
) )
} }
} }

View File

@ -0,0 +1,53 @@
package com.bitchat.android.ui
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.BitchatMessage
internal fun BitchatMessage.isFromSelf(
currentUserNickname: String,
myPeerId: String,
): Boolean =
senderPeerID == myPeerId ||
sender == currentUserNickname ||
sender.startsWith("$currentUserNickname#")
internal fun normalizeMessageUrl(rawUrl: String): String =
if (
rawUrl.startsWith("http://", ignoreCase = true) ||
rawUrl.startsWith("https://", ignoreCase = true)
) {
rawUrl
} else {
"https://$rawUrl"
}
internal fun openMessageUrl(context: Context, rawUrl: String): Boolean =
runCatching {
val intent = Intent(Intent.ACTION_VIEW, normalizeMessageUrl(rawUrl).toUri())
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}.isSuccess
internal fun channelForGeohash(geohash: String): GeohashChannel {
val level = when (geohash.length) {
in 0..2 -> GeohashChannelLevel.REGION
in 3..4 -> GeohashChannelLevel.PROVINCE
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
else -> GeohashChannelLevel.BLOCK
}
return GeohashChannel(level, geohash.lowercase())
}
internal fun navigateToGeohash(context: Context, geohash: String): Boolean =
runCatching {
val locationManager = LocationChannelManager.getInstance(context)
locationManager.setTeleported(true)
locationManager.select(ChannelID.Location(channelForGeohash(geohash)))
}.isSuccess

View File

@ -2,25 +2,22 @@ package com.bitchat.android.ui.media
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
@ -58,23 +55,21 @@ fun AudioMessageItem(
timeFormatter = timeFormatter timeFormatter = timeFormatter
) )
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) } AnnotatedClickableText(
Text(
text = headerText, text = headerText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface, color = colorScheme.onSurface,
modifier = Modifier.pointerInput(message.id) {
detectTapGestures(onTap = { pos ->
val layout = headerLayout ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(pos)
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
if (ann.isNotEmpty() && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(ann.first().item)
}
}, onLongPress = { onMessageLongPress?.invoke(message) })
},
onTextLayout = { headerLayout = it }
) )
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {

View File

@ -3,13 +3,11 @@ package com.bitchat.android.ui.media
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -18,21 +16,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.*
@Composable @Composable
fun ImageMessageItem( fun ImageMessageItem(
@ -58,23 +53,21 @@ fun ImageMessageItem(
timeFormatter = timeFormatter timeFormatter = timeFormatter
) )
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) } AnnotatedClickableText(
Text(
text = headerText, text = headerText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface, color = colorScheme.onSurface,
modifier = Modifier.pointerInput(message.id) {
detectTapGestures(onTap = { pos ->
val layout = headerLayout ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(pos)
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
if (ann.isNotEmpty() && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(ann.first().item)
}
}, onLongPress = { onMessageLongPress?.invoke(message) })
},
onTextLayout = { headerLayout = it }
) )
val context = LocalContext.current val context = LocalContext.current

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">فتح قسم حول</string>
</resources> </resources>

View File

@ -387,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">পরিচিতি খুলুন</string>
</resources> </resources>

View File

@ -401,4 +401,5 @@
<string name="verify_success_title">Verifiziert</string> <string name="verify_success_title">Verifiziert</string>
<string name="verify_success_body">Du hast %1$s verifiziert</string> <string name="verify_success_body">Du hast %1$s verifiziert</string>
<string name="verify_success_system_message">verifiziert %1$s</string> <string name="verify_success_system_message">verifiziert %1$s</string>
<string name="cd_open_about">Info öffnen</string>
</resources> </resources>

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">Verificado</string> <string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Verificaste a %1$s</string> <string name="verify_success_body">Verificaste a %1$s</string>
<string name="verify_success_system_message">verificado %1$s</string> <string name="verify_success_system_message">verificado %1$s</string>
<string name="cd_open_about">Abrir Acerca de</string>
</resources> </resources>

View File

@ -387,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">باز کردن درباره</string>
</resources> </resources>

View File

@ -280,7 +280,6 @@
<string name="grant_permissions">Payagan</string> <string name="grant_permissions">Payagan</string>
<string name="location_tracking_warning">HINDI sinusubaybayan ng bitchat ang lokasyon mo</string> <string name="location_tracking_warning">HINDI sinusubaybayan ng bitchat ang lokasyon mo</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">TAO</string> <string name="geohash_people_header">TAO</string>
<string name="nobody_around">walang tao sa paligid…</string> <string name="nobody_around">walang tao sa paligid…</string>
@ -400,4 +399,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buksan ang Tungkol</string>
</resources> </resources>

View File

@ -281,7 +281,6 @@
<string name="grant_permissions">Accorder les autorisations</string> <string name="grant_permissions">Accorder les autorisations</string>
<string name="location_tracking_warning">bitchat ne suit PAS ta position</string> <string name="location_tracking_warning">bitchat ne suit PAS ta position</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">PERSONNES</string> <string name="geohash_people_header">PERSONNES</string>
<string name="nobody_around">personne aux alentours…</string> <string name="nobody_around">personne aux alentours…</string>
@ -414,4 +413,5 @@
<string name="verify_success_title">Vérifié</string> <string name="verify_success_title">Vérifié</string>
<string name="verify_success_body">Vous avez vérifié %1$s</string> <string name="verify_success_body">Vous avez vérifié %1$s</string>
<string name="verify_success_system_message">vérifié %1$s</string> <string name="verify_success_system_message">vérifié %1$s</string>
<string name="cd_open_about">Ouvrir À propos</string>
</resources> </resources>

View File

@ -53,5 +53,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">פתיחת אודות</string>
</resources> </resources>

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">परिचय खोलें</string>
</resources> </resources>

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buka Tentang</string>
</resources> </resources>

View File

@ -340,7 +340,6 @@
<!-- Simboli e stringhe speciali --> <!-- Simboli e stringhe speciali -->
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="mention_suggestion_at">@%1$s</string> <string name="mention_suggestion_at">@%1$s</string>
<string name="image_counter">%1$d / %2$d</string> <string name="image_counter">%1$d / %2$d</string>
@ -434,4 +433,5 @@
<string name="verify_success_title">Verificato</string> <string name="verify_success_title">Verificato</string>
<string name="verify_success_body">Hai verificato %1$s</string> <string name="verify_success_body">Hai verificato %1$s</string>
<string name="verify_success_system_message">verificato %1$s</string> <string name="verify_success_system_message">verificato %1$s</string>
<string name="cd_open_about">Apri Informazioni</string>
</resources> </resources>

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">検証済み</string> <string name="verify_success_title">検証済み</string>
<string name="verify_success_body">%1$s を検証しました</string> <string name="verify_success_body">%1$s を検証しました</string>
<string name="verify_success_system_message">%1$s を検証しました</string> <string name="verify_success_system_message">%1$s を検証しました</string>
<string name="cd_open_about">このアプリについてを開く</string>
</resources> </resources>

View File

@ -387,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">აპის შესახებ გახსნა</string>
</resources> </resources>

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">정보 열기</string>
</resources> </resources>

View File

@ -289,7 +289,6 @@
<string name="grant_permissions">Omeo Alalana</string> <string name="grant_permissions">Omeo Alalana</string>
<string name="location_tracking_warning">Ny bitchat dia TSY manaraka ny toerananao</string> <string name="location_tracking_warning">Ny bitchat dia TSY manaraka ny toerananao</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">OLONA</string> <string name="geohash_people_header">OLONA</string>
<string name="nobody_around">tsy misy olona manodidina...</string> <string name="nobody_around">tsy misy olona manodidina...</string>
@ -414,4 +413,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Sokafy ny momba</string>
</resources> </resources>

View File

@ -40,5 +40,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buka Perihal</string>
</resources> </resources>

View File

@ -280,7 +280,6 @@
<string name="grant_permissions">अनुमति दिनुहोस्</string> <string name="grant_permissions">अनुमति दिनुहोस्</string>
<string name="location_tracking_warning">bitchat ले तपाईंको स्थान पछ्याउँदैन</string> <string name="location_tracking_warning">bitchat ले तपाईंको स्थान पछ्याउँदैन</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">मानिसहरू</string> <string name="geohash_people_header">मानिसहरू</string>
<string name="nobody_around">वरिपरि कोही छैन…</string> <string name="nobody_around">वरिपरि कोही छैन…</string>
@ -400,4 +399,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">परिचय खोल्नुहोस्</string>
</resources> </resources>

View File

@ -340,7 +340,6 @@
<!-- Symbolen &amp; speciale strings --> <!-- Symbolen &amp; speciale strings -->
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="mention_suggestion_at">@%1$s</string> <string name="mention_suggestion_at">@%1$s</string>
<string name="image_counter">%1$d / %2$d</string> <string name="image_counter">%1$d / %2$d</string>
@ -432,4 +431,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Info openen</string>
</resources> </resources>

View File

@ -387,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">ایپ بارے کھولو</string>
</resources> </resources>

View File

@ -53,5 +53,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Otwórz informacje</string>
</resources> </resources>

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">Verificado</string> <string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Você verificou %1$s</string> <string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string> <string name="verify_success_system_message">verificou %1$s</string>
<string name="cd_open_about">Abrir Sobre</string>
</resources> </resources>

View File

@ -264,7 +264,6 @@
<string name="grant_permissions">Выдать разрешения</string> <string name="grant_permissions">Выдать разрешения</string>
<string name="location_tracking_warning">bitchat НЕ отслеживает вашу геопозицию</string> <string name="location_tracking_warning">bitchat НЕ отслеживает вашу геопозицию</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">ЛЮДИ</string> <string name="geohash_people_header">ЛЮДИ</string>
<string name="nobody_around">никого рядом…</string> <string name="nobody_around">никого рядом…</string>
@ -390,4 +389,5 @@
<string name="verify_success_title">Проверено</string> <string name="verify_success_title">Проверено</string>
<string name="verify_success_body">Вы проверили %1$s</string> <string name="verify_success_body">Вы проверили %1$s</string>
<string name="verify_success_system_message">проверен %1$s</string> <string name="verify_success_system_message">проверен %1$s</string>
<string name="cd_open_about">Открыть раздел «О приложении»</string>
</resources> </resources>

View File

@ -264,7 +264,6 @@
<string name="grant_permissions">Ge behörigheter</string> <string name="grant_permissions">Ge behörigheter</string>
<string name="location_tracking_warning">bitchat spårar INTE din plats</string> <string name="location_tracking_warning">bitchat spårar INTE din plats</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">PERSONER</string> <string name="geohash_people_header">PERSONER</string>
<string name="nobody_around">ingen i närheten…</string> <string name="nobody_around">ingen i närheten…</string>
@ -388,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Öppna Om</string>
</resources> </resources>

View File

@ -40,5 +40,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
</resources> </resources>

View File

@ -387,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">เปิดเกี่ยวกับ</string>
</resources> </resources>

View File

@ -264,7 +264,6 @@
<string name="grant_permissions">İzin ver</string> <string name="grant_permissions">İzin ver</string>
<string name="location_tracking_warning">bitchat konumunu takip etmez</string> <string name="location_tracking_warning">bitchat konumunu takip etmez</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">KİŞİLER</string> <string name="geohash_people_header">KİŞİLER</string>
<string name="nobody_around">yakında kimse yok…</string> <string name="nobody_around">yakında kimse yok…</string>
@ -388,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Hakkındayı</string>
</resources> </resources>

View File

@ -40,5 +40,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
</resources> </resources>

View File

@ -400,4 +400,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">تعارف کھولیں</string>
</resources> </resources>

View File

@ -387,4 +387,5 @@
<string name="verify_success_title">Verified</string> <string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string> <string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string> <string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Mở phần Giới thiệu</string>
</resources> </resources>

View File

@ -280,7 +280,6 @@
<string name="grant_permissions">授予权限</string> <string name="grant_permissions">授予权限</string>
<string name="location_tracking_warning">bitchat 不会跟踪你的位置</string> <string name="location_tracking_warning">bitchat 不会跟踪你的位置</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">成员</string> <string name="geohash_people_header">成员</string>
<string name="nobody_around">附近无人…</string> <string name="nobody_around">附近无人…</string>
@ -413,4 +412,5 @@
<string name="verify_success_title">已验证</string> <string name="verify_success_title">已验证</string>
<string name="verify_success_body">你已验证 %1$s</string> <string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string> <string name="verify_success_system_message">已验证 %1$s</string>
<string name="cd_open_about">打开“关于”</string>
</resources> </resources>

View File

@ -371,7 +371,7 @@
<string name="grant_permissions">Grant Permissions</string> <string name="grant_permissions">Grant Permissions</string>
<string name="location_tracking_warning">bitchat does NOT track your location</string> <string name="location_tracking_warning">bitchat does NOT track your location</string>
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string> <string name="cd_open_about">Open About</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">PEOPLE</string> <string name="geohash_people_header">PEOPLE</string>
<string name="nobody_around">nobody around...</string> <string name="nobody_around">nobody around...</string>

View File

@ -0,0 +1,44 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatUIUtilsTest {
private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
@Test
fun `text message metadata separates PoW badge with one space`() {
val message = BitchatMessage(
sender = "alice",
content = "hello",
timestamp = Date(0),
powDifficulty = 12,
)
assertEquals(
"00:00:00 ⛨12b",
formatTextMessageMetadata(message, timeFormatter).text,
)
}
@Test
fun `text message metadata omits non-positive PoW difficulty`() {
val message = BitchatMessage(
sender = "alice",
content = "hello",
timestamp = Date(0),
powDifficulty = 0,
)
assertEquals(
"00:00:00",
formatTextMessageMetadata(message, timeFormatter).text,
)
}
}

View File

@ -0,0 +1,63 @@
package com.bitchat.android.ui
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.model.BitchatMessage
import java.util.Date
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MessageInteractionUtilsTest {
@Test
fun `self detection accepts peer id nickname and nickname suffix`() {
assertTrue(message(sender = "alice", senderPeerId = "peer-a").isFromSelf("me", "peer-a"))
assertTrue(message(sender = "me").isFromSelf("me", "peer-a"))
assertTrue(message(sender = "me#1a2b").isFromSelf("me", "peer-a"))
}
@Test
fun `self detection rejects unrelated sender`() {
assertFalse(message(sender = "alice", senderPeerId = "peer-b").isFromSelf("me", "peer-a"))
}
@Test
fun `URL normalization preserves explicit HTTP schemes`() {
assertEquals("http://example.com", normalizeMessageUrl("http://example.com"))
assertEquals("HTTPS://example.com", normalizeMessageUrl("HTTPS://example.com"))
}
@Test
fun `URL normalization defaults bare URLs to HTTPS`() {
assertEquals("https://example.com", normalizeMessageUrl("example.com"))
}
@Test
fun `geohash channel precision matches navigation levels`() {
val expectedLevels = mapOf(
"9q" to GeohashChannelLevel.REGION,
"9q8" to GeohashChannelLevel.PROVINCE,
"9q8y" to GeohashChannelLevel.PROVINCE,
"9q8yy" to GeohashChannelLevel.CITY,
"9q8yyk" to GeohashChannelLevel.NEIGHBORHOOD,
"9q8yyk8" to GeohashChannelLevel.BLOCK,
)
expectedLevels.forEach { (geohash, level) ->
assertEquals(level, channelForGeohash(geohash).level)
}
}
@Test
fun `geohash channel normalizes casing`() {
assertEquals("9q8yy", channelForGeohash("9Q8YY").geohash)
}
private fun message(sender: String, senderPeerId: String? = null): BitchatMessage =
BitchatMessage(
sender = sender,
content = "hello",
timestamp = Date(0),
senderPeerID = senderPeerId,
)
}