diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt new file mode 100644 index 00000000..94529d77 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt new file mode 100644 index 00000000..3e1d7fe1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt @@ -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, +): 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, + 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(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 }, + ) +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt b/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt new file mode 100644 index 00000000..14247f2d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt b/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt deleted file mode 100644 index 19dabb57..00000000 --- a/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt +++ /dev/null @@ -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(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 - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 7273813b..e91e30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -26,10 +26,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.core.ui.utils.singleOrTripleClickable import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.button.BitChatBrandButton /** * Header components for ChatScreen @@ -356,16 +356,18 @@ private fun MainHeader( modifier = Modifier.fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = stringResource(R.string.app_brand), - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.primary, - modifier = Modifier.singleOrTripleClickable( - onSingleClick = onTitleClick, - onTripleClick = onTripleTitleClick - ) + BitChatBrandButton( + onClick = onTitleClick, + onTripleClick = onTripleTitleClick, + contentDescription = stringResource(R.string.cd_open_about), ) - + + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.primary, + ) + Spacer(modifier = Modifier.width(2.dp)) NicknameEditor( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 6dd02584..d227eb41 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -3,13 +3,9 @@ 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.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration 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.mesh.MeshService import androidx.compose.material3.ColorScheme @@ -50,9 +46,7 @@ fun formatMessageAsAnnotatedString( val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f // Determine if this message was sent by self - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") + val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) if (message.sender != "system") { // Get base color for this peer (iOS-style color assignment) @@ -117,7 +111,14 @@ fun formatMessageAsAnnotatedString( builder.pop() // 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) // Timestamp (and optional PoW badge) @@ -156,6 +157,108 @@ fun formatMessageAsAnnotatedString( 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. */ @@ -169,9 +272,7 @@ fun formatMessageHeaderAnnotatedString( val builder = AnnotatedString.Builder() val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") + val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) if (message.sender != "system") { val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) @@ -338,7 +439,7 @@ private fun appendIOSFormattedContent( currentUserNickname: String, baseColor: Color, isSelf: Boolean, - isDark: Boolean + contentColor: Color = baseColor, ) { // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() @@ -416,7 +517,7 @@ private fun appendIOSFormattedContent( val beforeText = content.substring(lastEnd, range.first) if (beforeText.isNotEmpty()) { builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) @@ -476,7 +577,7 @@ private fun appendIOSFormattedContent( "hashtag" -> { // Render general hashtags like normal content builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) @@ -530,7 +631,7 @@ private fun appendIOSFormattedContent( } else { // Fallback: treat as normal text builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) @@ -547,7 +648,7 @@ private fun appendIOSFormattedContent( if (lastEnd < content.length) { val remainingText = content.substring(lastEnd) builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 045ea127..4ca8674f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -1,16 +1,17 @@ package com.bitchat.android.ui -import androidx.compose.material3.* +import androidx.compose.material3.ColorScheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.text.SimpleDateFormat import kotlin.random.Random /** @@ -18,7 +19,6 @@ import kotlin.random.Random */ private enum class CharacterAnimationState { ENCRYPTED, // Showing random encrypted characters - DECRYPTING, // Transitioning to final character FINAL // Showing final decrypted character } @@ -69,205 +69,111 @@ object PoWMiningTracker { } /** - * Enhanced message display that shows matrix animation during PoW mining - * Formats message like a normal message but animates only the content portion + * Shows the active PoW animation inside the same two-row layout used by static text messages. */ @Composable fun MessageWithMatrixAnimation( - message: com.bitchat.android.model.BitchatMessage, - messages: List = emptyList(), + message: BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.MeshService, - colorScheme: androidx.compose.material3.ColorScheme, - timeFormatter: java.text.SimpleDateFormat, + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?, - onImageClick: ((String, List, Int) -> Unit)?, - modifier: Modifier = Modifier + onMessageLongPress: ((BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier, ) { - val isAnimating = shouldAnimateMessage(message.id) - - if (isAnimating) { - // During animation: Show formatted message with animated content - AnimatedMessageDisplay( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - 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 - ) - } + AnimatedMessageDisplay( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + modifier = modifier, + ) } /** - * Display message with proper formatting but animated content - * Uses IDENTICAL layout structure as normal message for pixel-perfect alignment + * Animates only the body content; sender, metadata, gestures, and spacing remain stable. */ @Composable private fun AnimatedMessageDisplay( - message: com.bitchat.android.model.BitchatMessage, + message: BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.MeshService, - colorScheme: androidx.compose.material3.ColorScheme, - timeFormatter: java.text.SimpleDateFormat, - modifier: Modifier = Modifier + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier, ) { - // Get the animated content text - var animatedContent by remember(message.content) { mutableStateOf(message.content) } - val isAnimating = shouldAnimateMessage(message.id) + var animatedContent by remember(message.id, message.content) { + mutableStateOf(message.content) + } // 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 -> if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED }) } - // Update animated content when animation state changes - LaunchedEffect(isAnimating, message.content) { - if (isAnimating && message.content.isNotEmpty()) { - val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() - - // Start character animations with staggered delays (like JS version) - message.content.forEachIndexed { index, targetChar -> - if (targetChar != ' ') { // Skip spaces - launch { - delay(index * 50L) // Stagger start like JS version - - // Animate this character indefinitely in a loop - while (true) { - // Animate with random characters - while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { - // Generate random encrypted character for this position - val newContent = animatedContent.toCharArray() - if (index < newContent.size) { - newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] - animatedContent = String(newContent) - } - - delay(100L) // Change character every 100ms like JS - - // Random chance to reveal (10% like JS version) - if (Random.nextFloat() < 0.1f) { - // Reveal the final character - val finalContent = animatedContent.toCharArray() - if (index < finalContent.size) { - finalContent[index] = targetChar - animatedContent = String(finalContent) - } - - // Mark as revealed - val finalStates = characterStates.toMutableList() - finalStates[index] = CharacterAnimationState.FINAL - characterStates = finalStates - break - } + LaunchedEffect(message.id, message.content) { + if (message.content.isEmpty()) return@LaunchedEffect + + val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() + + // Start character animations with staggered delays (like JS version). + message.content.forEachIndexed { index, targetChar -> + if (targetChar != ' ') { + launch { + delay(index * 50L) + + while (true) { + while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { + val newContent = animatedContent.toCharArray() + if (index < newContent.size) { + newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] + animatedContent = String(newContent) + } + + delay(100L) + + if (Random.nextFloat() < 0.1f) { + val finalContent = animatedContent.toCharArray() + if (index < finalContent.size) { + finalContent[index] = targetChar + animatedContent = String(finalContent) + } + + 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 - ) - ) -} - -/** - * 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( + TextMessageLayout( message = message, currentUserNickname = currentUserNickname, meshService = meshService, 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 - } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 88b24015..73fac04d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,50 +1,57 @@ package com.bitchat.android.ui + 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.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.items import androidx.compose.foundation.lazy.rememberLazyListState - - -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +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.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp 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 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 @@ -266,23 +273,21 @@ fun MessageItem( timeFormatter = timeFormatter ) val haptic = LocalHapticFeedback.current - var headerLayout by remember { mutableStateOf(null) } - Text( + AnnotatedClickableText( 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, 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 @@ -354,18 +359,16 @@ fun MessageItem( // Display message with matrix animation for content MessageWithMatrixAnimation( message = message, - messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, - onImageClick = onImageClick, modifier = modifier ) - } else { - // Normal message display + } else if (message.sender == "system") { + // Keep system messages on the compact legacy line. val annotatedText = formatMessageAsAnnotatedString( message = message, currentUserNickname = currentUserNickname, @@ -373,80 +376,12 @@ fun MessageItem( colorScheme = colorScheme, 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 context = LocalContext.current - var textLayoutResult by remember { mutableStateOf(null) } Text( text = annotatedText, modifier = modifier.pointerInput(message) { 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 = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onMessageLongPress?.invoke(message) @@ -458,8 +393,129 @@ fun MessageItem( overflow = TextOverflow.Visible, style = androidx.compose.ui.text.TextStyle( 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), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt new file mode 100644 index 00000000..275dbaf8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 6af2cc51..7c0a1ac6 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -2,25 +2,22 @@ package com.bitchat.android.ui.media import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import com.bitchat.android.R +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme @@ -58,23 +55,21 @@ fun AudioMessageItem( timeFormatter = timeFormatter ) val haptic = LocalHapticFeedback.current - var headerLayout by remember { mutableStateOf(null) } - Text( + AnnotatedClickableText( 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, 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) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 43ae84f9..35b0bbea 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -3,13 +3,11 @@ package com.bitchat.android.ui.media import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* 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.layout.ContentScale import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.text.font.FontFamily import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import java.text.SimpleDateFormat -import java.util.* @Composable fun ImageMessageItem( @@ -58,23 +53,21 @@ fun ImageMessageItem( timeFormatter = timeFormatter ) val haptic = LocalHapticFeedback.current - var headerLayout by remember { mutableStateOf(null) } - Text( + AnnotatedClickableText( 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, 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 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3d72337a..4d2ffec0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + فتح قسم حول diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 3cda37a6..c5902ee2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + পরিচিতি খুলুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c6cb4aac..9002f9bd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -401,4 +401,5 @@ Verifiziert Du hast %1$s verifiziert verifiziert %1$s + Info öffnen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b8fed3aa..cf6b336e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -400,4 +400,5 @@ Verificado Verificaste a %1$s verificado %1$s + Abrir Acerca de diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8ad97fe9..84273529 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + باز کردن درباره diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 34e1d5f5..65e4cbd2 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -280,7 +280,6 @@ Payagan HINDI sinusubaybayan ng bitchat ang lokasyon mo @ - bitchat/ · ⧉ TAO walang tao sa paligid… @@ -400,4 +399,5 @@ Verified You verified %1$s verified %1$s + Buksan ang Tungkol diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9828a76b..aee12bf1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -281,7 +281,6 @@ Accorder les autorisations bitchat ne suit PAS ta position @ - bitchat/ · ⧉ PERSONNES personne aux alentours… @@ -414,4 +413,5 @@ Vérifié Vous avez vérifié %1$s vérifié %1$s + Ouvrir À propos diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index e18b57b8..a78d83b6 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -53,5 +53,5 @@ Verified You verified %1$s verified %1$s + פתיחת אודות - diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 02d64711..926cc3a4 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + परिचय खोलें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index fb9830be..bb15e3bb 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + Buka Tentang diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3db4e85e..c10a8575 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -340,7 +340,6 @@ @ - bitchat/ · ⧉ @%1$s %1$d / %2$d @@ -434,4 +433,5 @@ Verificato Hai verificato %1$s verificato %1$s + Apri Informazioni diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index df489157..eb9d6ba1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -400,4 +400,5 @@ 検証済み %1$s を検証しました %1$s を検証しました + このアプリについてを開く diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 2d1c6693..1e3187d0 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + აპის შესახებ გახსნა diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 68d46136..829cd1f5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + 정보 열기 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 57676dec..a7edd747 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -289,7 +289,6 @@ Omeo Alalana Ny bitchat dia TSY manaraka ny toerananao @ - bitchat/ · ⧉ OLONA tsy misy olona manodidina... @@ -414,4 +413,5 @@ Verified You verified %1$s verified %1$s + Sokafy ny momba diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index b4b23655..9b6f2bb8 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -40,5 +40,5 @@ Verified You verified %1$s verified %1$s + Buka Perihal - diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 252f81c4..f1dbb3c6 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -280,7 +280,6 @@ अनुमति दिनुहोस् bitchat ले तपाईंको स्थान पछ्याउँदैन @ - bitchat/ · ⧉ मानिसहरू वरिपरि कोही छैन… @@ -400,4 +399,5 @@ Verified You verified %1$s verified %1$s + परिचय खोल्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 59705572..6a014f87 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -340,7 +340,6 @@ @ - bitchat/ · ⧉ @%1$s %1$d / %2$d @@ -432,4 +431,5 @@ Verified You verified %1$s verified %1$s + Info openen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 58135f7c..3b94bd8c 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + ایپ بارے کھولو diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index cdbffdd0..1579f9b3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -53,5 +53,5 @@ Verified You verified %1$s verified %1$s + Otwórz informacje - diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 147b5f37..274bbaae 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -400,4 +400,5 @@ Verificado Você verificou %1$s verificou %1$s + Abrir Sobre diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index dade7d1d..d1d27acd 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -264,7 +264,6 @@ Выдать разрешения bitchat НЕ отслеживает вашу геопозицию @ - bitchat/ · ⧉ ЛЮДИ никого рядом… @@ -390,4 +389,5 @@ Проверено Вы проверили %1$s проверен %1$s + Открыть раздел «О приложении» diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 9ac7df67..aa3194ed 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -264,7 +264,6 @@ Ge behörigheter bitchat spårar INTE din plats @ - bitchat/ · ⧉ PERSONER ingen i närheten… @@ -388,4 +387,5 @@ Verified You verified %1$s verified %1$s + Öppna Om diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 1e487548..a47d4dbd 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -40,5 +40,5 @@ Verified You verified %1$s verified %1$s + அறிமுகத்தைத் திற - diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 4a4c5021..5b2ba927 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + เปิดเกี่ยวกับ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a2df9dfe..d27d1b59 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -264,7 +264,6 @@ İzin ver bitchat konumunu takip etmez @ - bitchat/ · ⧉ KİŞİLER yakında kimse yok… @@ -388,4 +387,5 @@ Verified You verified %1$s verified %1$s + Hakkında’yı aç diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2c631d04..9ce840ac 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -40,5 +40,5 @@ Verified You verified %1$s verified %1$s + Відкрити розділ «Про застосунок» - diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 291c3e9a..1d8399de 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + تعارف کھولیں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index a1315617..6ab72813 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + Mở phần Giới thiệu diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 46caf1af..8a28e5f7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -280,7 +280,6 @@ 授予权限 bitchat 不会跟踪你的位置 @ - bitchat/ · ⧉ 成员 附近无人… @@ -413,4 +412,5 @@ 已验证 你已验证 %1$s 已验证 %1$s + 打开“关于” diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ece95a7..e674112c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,7 +371,7 @@ Grant Permissions bitchat does NOT track your location @ - bitchat/ + Open About · ⧉ PEOPLE nobody around... diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt new file mode 100644 index 00000000..304166c0 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -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, + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt new file mode 100644 index 00000000..90e279e1 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt @@ -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, + ) +}