mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
ui: refresh chat branding and text message layout (#767)
* Add BitChatBrandButton and BitChatIcon Add a dedicated brand button component and custom vector icon to the UI library. Refactor the chat header to use this component, replacing the previous modifier-based multi-click implementation. * **BitChatBrandButton**: A new Composable that encapsulates single and triple click detection logic using coroutine delays and tap counting. * **BitChatIcon**: A custom pixel-style `ImageVector` representing the brand. * **ChatHeader**: Updated to use the new brand button and included a visual separator (`/`) in the layout. * **ModifierExt.kt**: Removed the `singleOrTripleClickable` extension as its functionality is now handled internally by the brand button component. * ui: refactor text messages to two-row layout Refactor the message list items to use a two-row layout for standard text messages, while preserving the legacy compact format for system messages. * **UI Formatting**: Added `formatTextMessageSender`, `formatTextMessageMetadata`, and `formatTextMessageBody` in `ChatUIUtils.kt` to separate the rendering of sender info, timestamps/PoW, and message content. * **Layout Change**: Replaced the single-block `Text` component with a `Column` containing a `Row` (Sender + Metadata) and a message body `Text` block for standard messages. * **Interaction**: Updated `pointerInput` and `detectTapGestures` to handle nickname clicks in the header row and geohash/URL clicks within the message body row. * **Styling**: Refined `appendIOSFormattedContent` to support a `contentColor` parameter and applied specific font weights and colors (e.g., orange for self-messages) to match the platform's design language. * **System Messages**: Explicitly kept messages where `sender == "system"` on the original compact layout. * refactor(ui): extract annotated text pointer handling * fix(ui): address chat redesign review feedback * fix(ui): align PoW animation with text layout * refactor(ui): extract message interaction helpers --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
parent
61588db474
commit
92d07b22fa
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
))
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -400,4 +400,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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -401,4 +401,5 @@
|
||||
<string name="verify_success_title">Verifiziert</string>
|
||||
<string name="verify_success_body">Du hast %1$s verifiziert</string>
|
||||
<string name="verify_success_system_message">verifiziert %1$s</string>
|
||||
<string name="cd_open_about">Info öffnen</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,5 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Verificaste a %1$s</string>
|
||||
<string name="verify_success_system_message">verificado %1$s</string>
|
||||
<string name="cd_open_about">Abrir Acerca de</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -400,4 +399,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">Buksan ang Tungkol</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
@ -414,4 +413,5 @@
|
||||
<string name="verify_success_title">Vérifié</string>
|
||||
<string name="verify_success_body">Vous avez vérifié %1$s</string>
|
||||
<string name="verify_success_system_message">vérifié %1$s</string>
|
||||
<string name="cd_open_about">Ouvrir À propos</string>
|
||||
</resources>
|
||||
|
||||
@ -53,5 +53,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>
|
||||
|
||||
|
||||
@ -400,4 +400,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>
|
||||
|
||||
@ -400,4 +400,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">Buka Tentang</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
@ -434,4 +433,5 @@
|
||||
<string name="verify_success_title">Verificato</string>
|
||||
<string name="verify_success_body">Hai verificato %1$s</string>
|
||||
<string name="verify_success_system_message">verificato %1$s</string>
|
||||
<string name="cd_open_about">Apri Informazioni</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,5 @@
|
||||
<string name="verify_success_title">検証済み</string>
|
||||
<string name="verify_success_body">%1$s を検証しました</string>
|
||||
<string name="verify_success_system_message">%1$s を検証しました</string>
|
||||
<string name="cd_open_about">このアプリについてを開く</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -400,4 +400,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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -40,5 +40,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">Buka Perihal</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -400,4 +399,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>
|
||||
|
||||
@ -340,7 +340,6 @@
|
||||
|
||||
<!-- Symbolen & 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>
|
||||
@ -432,4 +431,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">Info openen</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -53,5 +53,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">Otwórz informacje</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@ -400,4 +400,5 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Você verificou %1$s</string>
|
||||
<string name="verify_success_system_message">verificou %1$s</string>
|
||||
<string name="cd_open_about">Abrir Sobre</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
@ -390,4 +389,5 @@
|
||||
<string name="verify_success_title">Проверено</string>
|
||||
<string name="verify_success_body">Вы проверили %1$s</string>
|
||||
<string name="verify_success_system_message">проверен %1$s</string>
|
||||
<string name="cd_open_about">Открыть раздел «О приложении»</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
@ -388,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">Öppna Om</string>
|
||||
</resources>
|
||||
|
||||
@ -40,5 +40,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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -388,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">Hakkında’yı aç</string>
|
||||
</resources>
|
||||
|
||||
@ -40,5 +40,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>
|
||||
|
||||
|
||||
@ -400,4 +400,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>
|
||||
|
||||
@ -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">Mở phần Giới thiệu</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
@ -413,4 +412,5 @@
|
||||
<string name="verify_success_title">已验证</string>
|
||||
<string name="verify_success_body">你已验证 %1$s</string>
|
||||
<string name="verify_success_system_message">已验证 %1$s</string>
|
||||
<string name="cd_open_about">打开“关于”</string>
|
||||
</resources>
|
||||
|
||||
@ -371,7 +371,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>
|
||||
|
||||
44
app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt
Normal file
44
app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user