merge: resolve conflicts with latest main

This commit is contained in:
callebtc 2026-07-27 01:25:49 +02:00
commit 112f434f5c
44 changed files with 812 additions and 423 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.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(

View File

@ -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
))

View File

@ -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<com.bitchat.android.model.BitchatMessage> = 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<String>, 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
}
}

View File

@ -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<TextLayoutResult?>(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<TextLayoutResult?>(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),
)
}
}

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.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<TextLayoutResult?>(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) {

View File

@ -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<TextLayoutResult?>(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

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">تفعيل الموقع</string>
<string name="notices_alert_urgent_single">📌 إعلان عاجل من @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d إعلانات عاجلة جديدة — اضغط على الدبوس للعرض</string>
<string name="cd_open_about">فتح قسم حول</string>
</resources>

View File

@ -401,4 +401,5 @@
<string name="notices_enable_location">লোকেশন চালু করুন</string>
<string name="notices_alert_urgent_single">📌 @%1$s-এর জরুরি নোটিশ: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$dটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন</string>
<string name="cd_open_about">পরিচিতি খুলুন</string>
</resources>

View File

@ -415,4 +415,5 @@
<string name="notices_enable_location">standort aktivieren</string>
<string name="notices_alert_urgent_single">📌 dringender hinweis von @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d neue dringende hinweise — tippe zum ansehen auf den pin</string>
<string name="cd_open_about">Info öffnen</string>
</resources>

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">activar ubicación</string>
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d avisos urgentes nuevos — toca el pin para verlos</string>
<string name="cd_open_about">Abrir Acerca de</string>
</resources>

View File

@ -401,4 +401,5 @@
<string name="notices_enable_location">فعال‌سازی موقعیت</string>
<string name="notices_alert_urgent_single">📌 اطلاعیهٔ فوری از @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d اطلاعیهٔ فوری جدید — برای مشاهده روی سنجاق بزنید</string>
<string name="cd_open_about">باز کردن درباره</string>
</resources>

View File

@ -280,7 +280,6 @@
<string name="grant_permissions">Payagan</string>
<string name="location_tracking_warning">HINDI sinusubaybayan ng bitchat ang lokasyon mo</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">TAO</string>
<string name="nobody_around">walang tao sa paligid…</string>
@ -414,4 +413,5 @@
<string name="notices_enable_location">i-on ang lokasyon</string>
<string name="notices_alert_urgent_single">📌 agarang paunawa mula kay @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d bagong agarang paunawa — i-tap ang pin para makita</string>
<string name="cd_open_about">Buksan ang Tungkol</string>
</resources>

View File

@ -281,7 +281,6 @@
<string name="grant_permissions">Accorder les autorisations</string>
<string name="location_tracking_warning">bitchat ne suit PAS ta position</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">PERSONNES</string>
<string name="nobody_around">personne aux alentours…</string>
@ -428,4 +427,5 @@
<string name="notices_enable_location">activer la localisation</string>
<string name="notices_alert_urgent_single">📌 annonce urgente de @%1$s : %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nouvelles annonces urgentes — touche l\'épingle pour voir</string>
<string name="cd_open_about">Ouvrir À propos</string>
</resources>

View File

@ -67,4 +67,5 @@
<string name="notices_enable_location">הפעל מיקום</string>
<string name="notices_alert_urgent_single">📌 מודעה דחופה מאת @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d מודעות דחופות חדשות — הקש על הנעץ לצפייה</string>
<string name="cd_open_about">פתיחת אודות</string>
</resources>

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">लोकेशन सक्षम करें</string>
<string name="notices_alert_urgent_single">📌 @%1$s की ज़रूरी सूचना: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें</string>
<string name="cd_open_about">परिचय खोलें</string>
</resources>

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">aktifkan lokasi</string>
<string name="notices_alert_urgent_single">📌 pengumuman mendesak dari @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d pengumuman mendesak baru — ketuk pin untuk melihat</string>
<string name="cd_open_about">Buka Tentang</string>
</resources>

View File

@ -340,7 +340,6 @@
<!-- Simboli e stringhe speciali -->
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="image_counter">%1$d / %2$d</string>
@ -448,4 +447,5 @@
<string name="notices_enable_location">attiva posizione</string>
<string name="notices_alert_urgent_single">📌 avviso urgente da @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nuovi avvisi urgenti — tocca la puntina per vederli</string>
<string name="cd_open_about">Apri Informazioni</string>
</resources>

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">位置情報を有効化</string>
<string name="notices_alert_urgent_single">📌 @%1$sからの緊急のお知らせ: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 新しい緊急のお知らせが%1$d件 — ピンをタップして表示</string>
<string name="cd_open_about">このアプリについてを開く</string>
</resources>

View File

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

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">위치 활성화</string>
<string name="notices_alert_urgent_single">📌 @%1$s님의 긴급 공지: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 새 긴급 공지 %1$d개 — 핀을 탭하여 확인</string>
<string name="cd_open_about">정보 열기</string>
</resources>

View File

@ -289,7 +289,6 @@
<string name="grant_permissions">Omeo Alalana</string>
<string name="location_tracking_warning">Ny bitchat dia TSY manaraka ny toerananao</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">OLONA</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_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Sokafy ny momba</string>
</resources>

View File

@ -54,4 +54,5 @@
<string name="notices_enable_location">aktifkan lokasi</string>
<string name="notices_alert_urgent_single">📌 pengumuman segera daripada @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d pengumuman segera baharu — ketik pin untuk melihat</string>
<string name="cd_open_about">Buka Perihal</string>
</resources>

View File

@ -280,7 +280,6 @@
<string name="grant_permissions">अनुमति दिनुहोस्</string>
<string name="location_tracking_warning">bitchat ले तपाईंको स्थान पछ्याउँदैन</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">मानिसहरू</string>
<string name="nobody_around">वरिपरि कोही छैन…</string>
@ -414,4 +413,5 @@
<string name="notices_enable_location">स्थान सक्षम गर</string>
<string name="notices_alert_urgent_single">📌 @%1$sको जरुरी सूचना: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर</string>
<string name="cd_open_about">परिचय खोल्नुहोस्</string>
</resources>

View File

@ -340,7 +340,6 @@
<!-- Symbolen &amp; speciale strings -->
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="image_counter">%1$d / %2$d</string>
@ -446,4 +445,5 @@
<string name="notices_enable_location">locatie inschakelen</string>
<string name="notices_alert_urgent_single">📌 dringende mededeling van @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nieuwe dringende mededelingen — tik op de pin om te bekijken</string>
<string name="cd_open_about">Info openen</string>
</resources>

View File

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

View File

@ -67,4 +67,5 @@
<string name="notices_enable_location">włącz lokalizację</string>
<string name="notices_alert_urgent_single">📌 pilne ogłoszenie od @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć</string>
<string name="cd_open_about">Otwórz informacje</string>
</resources>

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">ativar localização</string>
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d novos avisos urgentes — toca no pin para ver</string>
<string name="cd_open_about">Abrir Sobre</string>
</resources>

View File

@ -264,7 +264,6 @@
<string name="grant_permissions">Выдать разрешения</string>
<string name="location_tracking_warning">bitchat НЕ отслеживает вашу геопозицию</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">ЛЮДИ</string>
<string name="nobody_around">никого рядом…</string>
@ -404,4 +403,5 @@
<string name="notices_enable_location">включить локацию</string>
<string name="notices_alert_urgent_single">📌 срочное объявление от @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d новых срочных объявлений — нажми на булавку, чтобы посмотреть</string>
<string name="cd_open_about">Открыть раздел «О приложении»</string>
</resources>

View File

@ -264,7 +264,6 @@
<string name="grant_permissions">Ge behörigheter</string>
<string name="location_tracking_warning">bitchat spårar INTE din plats</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">PERSONER</string>
<string name="nobody_around">ingen i närheten…</string>
@ -402,4 +401,5 @@
<string name="notices_enable_location">aktivera plats</string>
<string name="notices_alert_urgent_single">📌 brådskande anslag från @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nya brådskande anslag — tryck på nålen för att visa</string>
<string name="cd_open_about">Öppna Om</string>
</resources>

View File

@ -54,4 +54,5 @@
<string name="notices_enable_location">இடத்தை இயக்கு</string>
<string name="notices_alert_urgent_single">📌 @%1$s இன் அவசர அறிவிப்பு: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும்</string>
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
</resources>

View File

@ -401,4 +401,5 @@
<string name="notices_enable_location">เปิดใช้งานตำแหน่ง</string>
<string name="notices_alert_urgent_single">📌 ประกาศด่วนจาก @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 ประกาศด่วนใหม่ %1$d รายการ — แตะหมุดเพื่อดู</string>
<string name="cd_open_about">เปิดเกี่ยวกับ</string>
</resources>

View File

@ -264,7 +264,6 @@
<string name="grant_permissions">İzin ver</string>
<string name="location_tracking_warning">bitchat konumunu takip etmez</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">KİŞİLER</string>
<string name="nobody_around">yakında kimse yok…</string>
@ -402,4 +401,5 @@
<string name="notices_enable_location">konumu etkinleştir</string>
<string name="notices_alert_urgent_single">📌 @%1$s kişisinden acil duyuru: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d yeni acil duyuru — görmek için raptiyeye dokun</string>
<string name="cd_open_about">Hakkındayı</string>
</resources>

View File

@ -54,4 +54,5 @@
<string name="notices_enable_location">увімкнути локацію</string>
<string name="notices_alert_urgent_single">📌 термінове оголошення від @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d нових термінових оголошень — натисни на шпильку, щоб переглянути</string>
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
</resources>

View File

@ -414,4 +414,5 @@
<string name="notices_enable_location">لوکیشن فعال کریں</string>
<string name="notices_alert_urgent_single">📌 @%1$s کا فوری اعلان: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں</string>
<string name="cd_open_about">تعارف کھولیں</string>
</resources>

View File

@ -401,4 +401,5 @@
<string name="notices_enable_location">bật vị trí</string>
<string name="notices_alert_urgent_single">📌 thông báo khẩn từ @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d thông báo khẩn mới — chạm vào ghim để xem</string>
<string name="cd_open_about">Mở phần Giới thiệu</string>
</resources>

View File

@ -280,7 +280,6 @@
<string name="grant_permissions">授予权限</string>
<string name="location_tracking_warning">bitchat 不会跟踪你的位置</string>
<string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string>
<string name="geohash_people_header">成员</string>
<string name="nobody_around">附近无人…</string>
@ -427,4 +426,5 @@
<string name="notices_enable_location">启用位置</string>
<string name="notices_alert_urgent_single">📌 来自 @%1$s 的紧急公告:%2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d 条新紧急公告 — 点按图钉查看</string>
<string name="cd_open_about">打开“关于”</string>
</resources>

View File

@ -392,7 +392,7 @@
<string name="grant_permissions">Grant Permissions</string>
<string name="location_tracking_warning">bitchat does NOT track your location</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="geohash_people_header">PEOPLE</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,
)
}