mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +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.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header components for ChatScreen
|
* Header components for ChatScreen
|
||||||
@ -356,16 +356,18 @@ private fun MainHeader(
|
|||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
BitChatBrandButton(
|
||||||
text = stringResource(R.string.app_brand),
|
onClick = onTitleClick,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
onTripleClick = onTripleTitleClick,
|
||||||
color = colorScheme.primary,
|
contentDescription = stringResource(R.string.cd_open_about),
|
||||||
modifier = Modifier.singleOrTripleClickable(
|
|
||||||
onSingleClick = onTitleClick,
|
|
||||||
onTripleClick = onTripleTitleClick
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "/",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = colorScheme.primary,
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
|
||||||
NicknameEditor(
|
NicknameEditor(
|
||||||
|
|||||||
@ -3,13 +3,9 @@ package com.bitchat.android.ui
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Shield
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.mesh.MeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
@ -50,9 +46,7 @@ fun formatMessageAsAnnotatedString(
|
|||||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
|
||||||
// Determine if this message was sent by self
|
// Determine if this message was sent by self
|
||||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||||
message.sender == currentUserNickname ||
|
|
||||||
message.sender.startsWith("$currentUserNickname#")
|
|
||||||
|
|
||||||
if (message.sender != "system") {
|
if (message.sender != "system") {
|
||||||
// Get base color for this peer (iOS-style color assignment)
|
// Get base color for this peer (iOS-style color assignment)
|
||||||
@ -117,7 +111,14 @@ fun formatMessageAsAnnotatedString(
|
|||||||
builder.pop()
|
builder.pop()
|
||||||
|
|
||||||
// Message content with iOS-style hashtag and mention highlighting
|
// Message content with iOS-style hashtag and mention highlighting
|
||||||
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
|
appendIOSFormattedContent(
|
||||||
|
builder,
|
||||||
|
message.content,
|
||||||
|
message.mentions,
|
||||||
|
currentUserNickname,
|
||||||
|
baseColor,
|
||||||
|
isSelf,
|
||||||
|
)
|
||||||
|
|
||||||
// iOS-style timestamp at the END (smaller, grey)
|
// iOS-style timestamp at the END (smaller, grey)
|
||||||
// Timestamp (and optional PoW badge)
|
// Timestamp (and optional PoW badge)
|
||||||
@ -156,6 +157,108 @@ fun formatMessageAsAnnotatedString(
|
|||||||
return builder.toAnnotatedString()
|
return builder.toAnnotatedString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the sender label used by the two-row text-message layout.
|
||||||
|
*/
|
||||||
|
fun formatTextMessageSender(
|
||||||
|
message: BitchatMessage,
|
||||||
|
currentUserNickname: String,
|
||||||
|
meshService: MeshService,
|
||||||
|
colorScheme: ColorScheme
|
||||||
|
): AnnotatedString {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
val isDark =
|
||||||
|
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||||
|
val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||||
|
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||||
|
val (baseName, suffix) = splitSuffix(message.sender)
|
||||||
|
|
||||||
|
builder.pushStyle(
|
||||||
|
SpanStyle(
|
||||||
|
color = senderColor,
|
||||||
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
|
fontWeight = senderWeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.append("@")
|
||||||
|
val nicknameStart = builder.length
|
||||||
|
builder.append(truncateNickname(baseName))
|
||||||
|
val nicknameEnd = builder.length
|
||||||
|
if (!isSelf) {
|
||||||
|
builder.addStringAnnotation(
|
||||||
|
tag = "nickname_click",
|
||||||
|
annotation = message.originalSender ?: message.sender,
|
||||||
|
start = nicknameStart,
|
||||||
|
end = nicknameEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
builder.pop()
|
||||||
|
|
||||||
|
if (suffix.isNotEmpty()) {
|
||||||
|
builder.pushStyle(
|
||||||
|
SpanStyle(
|
||||||
|
color = senderColor.copy(alpha = 0.6f),
|
||||||
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
|
fontWeight = senderWeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.append(suffix)
|
||||||
|
builder.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the compact timestamp and optional proof-of-work label.
|
||||||
|
*/
|
||||||
|
fun formatTextMessageMetadata(
|
||||||
|
message: BitchatMessage,
|
||||||
|
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||||
|
): AnnotatedString {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
builder.pushStyle(
|
||||||
|
SpanStyle(
|
||||||
|
color = Color.Gray.copy(alpha = 0.7f),
|
||||||
|
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.append(timeFormatter.format(message.timestamp))
|
||||||
|
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
|
||||||
|
builder.append(" ⛨${bits}b")
|
||||||
|
}
|
||||||
|
builder.pop()
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build only the message body while retaining mention, URL and geohash styling.
|
||||||
|
*/
|
||||||
|
fun formatTextMessageBody(
|
||||||
|
message: BitchatMessage,
|
||||||
|
currentUserNickname: String,
|
||||||
|
meshService: MeshService,
|
||||||
|
colorScheme: ColorScheme
|
||||||
|
): AnnotatedString {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
val isDark =
|
||||||
|
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||||
|
val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||||
|
|
||||||
|
appendIOSFormattedContent(
|
||||||
|
builder = builder,
|
||||||
|
content = message.content,
|
||||||
|
mentions = message.mentions,
|
||||||
|
currentUserNickname = currentUserNickname,
|
||||||
|
baseColor = accentColor,
|
||||||
|
isSelf = isSelf,
|
||||||
|
contentColor = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
return builder.toAnnotatedString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
|
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
|
||||||
*/
|
*/
|
||||||
@ -169,9 +272,7 @@ fun formatMessageHeaderAnnotatedString(
|
|||||||
val builder = AnnotatedString.Builder()
|
val builder = AnnotatedString.Builder()
|
||||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
|
||||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||||
message.sender == currentUserNickname ||
|
|
||||||
message.sender.startsWith("$currentUserNickname#")
|
|
||||||
|
|
||||||
if (message.sender != "system") {
|
if (message.sender != "system") {
|
||||||
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||||
@ -338,7 +439,7 @@ private fun appendIOSFormattedContent(
|
|||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
baseColor: Color,
|
baseColor: Color,
|
||||||
isSelf: Boolean,
|
isSelf: Boolean,
|
||||||
isDark: Boolean
|
contentColor: Color = baseColor,
|
||||||
) {
|
) {
|
||||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||||
@ -416,7 +517,7 @@ private fun appendIOSFormattedContent(
|
|||||||
val beforeText = content.substring(lastEnd, range.first)
|
val beforeText = content.substring(lastEnd, range.first)
|
||||||
if (beforeText.isNotEmpty()) {
|
if (beforeText.isNotEmpty()) {
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = baseColor,
|
color = contentColor,
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||||
))
|
))
|
||||||
@ -476,7 +577,7 @@ private fun appendIOSFormattedContent(
|
|||||||
"hashtag" -> {
|
"hashtag" -> {
|
||||||
// Render general hashtags like normal content
|
// Render general hashtags like normal content
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = baseColor,
|
color = contentColor,
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||||
))
|
))
|
||||||
@ -530,7 +631,7 @@ private fun appendIOSFormattedContent(
|
|||||||
} else {
|
} else {
|
||||||
// Fallback: treat as normal text
|
// Fallback: treat as normal text
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = baseColor,
|
color = contentColor,
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||||
))
|
))
|
||||||
@ -547,7 +648,7 @@ private fun appendIOSFormattedContent(
|
|||||||
if (lastEnd < content.length) {
|
if (lastEnd < content.length) {
|
||||||
val remainingText = content.substring(lastEnd)
|
val remainingText = content.substring(lastEnd)
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = baseColor,
|
color = contentColor,
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||||
))
|
))
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitchat.android.mesh.MeshService
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -18,7 +19,6 @@ import kotlin.random.Random
|
|||||||
*/
|
*/
|
||||||
private enum class CharacterAnimationState {
|
private enum class CharacterAnimationState {
|
||||||
ENCRYPTED, // Showing random encrypted characters
|
ENCRYPTED, // Showing random encrypted characters
|
||||||
DECRYPTING, // Transitioning to final character
|
|
||||||
FINAL // Showing final decrypted character
|
FINAL // Showing final decrypted character
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,205 +69,111 @@ object PoWMiningTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced message display that shows matrix animation during PoW mining
|
* Shows the active PoW animation inside the same two-row layout used by static text messages.
|
||||||
* Formats message like a normal message but animates only the content portion
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageWithMatrixAnimation(
|
fun MessageWithMatrixAnimation(
|
||||||
message: com.bitchat.android.model.BitchatMessage,
|
message: BitchatMessage,
|
||||||
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
|
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: com.bitchat.android.mesh.MeshService,
|
meshService: MeshService,
|
||||||
colorScheme: androidx.compose.material3.ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
timeFormatter: java.text.SimpleDateFormat,
|
timeFormatter: SimpleDateFormat,
|
||||||
onNicknameClick: ((String) -> Unit)?,
|
onNicknameClick: ((String) -> Unit)?,
|
||||||
onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?,
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
modifier: Modifier = Modifier,
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
) {
|
||||||
val isAnimating = shouldAnimateMessage(message.id)
|
AnimatedMessageDisplay(
|
||||||
|
message = message,
|
||||||
if (isAnimating) {
|
currentUserNickname = currentUserNickname,
|
||||||
// During animation: Show formatted message with animated content
|
meshService = meshService,
|
||||||
AnimatedMessageDisplay(
|
colorScheme = colorScheme,
|
||||||
message = message,
|
timeFormatter = timeFormatter,
|
||||||
currentUserNickname = currentUserNickname,
|
onNicknameClick = onNicknameClick,
|
||||||
meshService = meshService,
|
onMessageLongPress = onMessageLongPress,
|
||||||
colorScheme = colorScheme,
|
modifier = modifier,
|
||||||
timeFormatter = timeFormatter,
|
)
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// After animation: Show complete normal message using existing formatter
|
|
||||||
val annotatedText = formatMessageAsAnnotatedString(
|
|
||||||
message = message,
|
|
||||||
currentUserNickname = currentUserNickname,
|
|
||||||
meshService = meshService,
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
timeFormatter = timeFormatter
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = annotatedText,
|
|
||||||
modifier = modifier,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
softWrap = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display message with proper formatting but animated content
|
* Animates only the body content; sender, metadata, gestures, and spacing remain stable.
|
||||||
* Uses IDENTICAL layout structure as normal message for pixel-perfect alignment
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnimatedMessageDisplay(
|
private fun AnimatedMessageDisplay(
|
||||||
message: com.bitchat.android.model.BitchatMessage,
|
message: BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: com.bitchat.android.mesh.MeshService,
|
meshService: MeshService,
|
||||||
colorScheme: androidx.compose.material3.ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
timeFormatter: java.text.SimpleDateFormat,
|
timeFormatter: SimpleDateFormat,
|
||||||
modifier: Modifier = Modifier
|
onNicknameClick: ((String) -> Unit)?,
|
||||||
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// Get the animated content text
|
var animatedContent by remember(message.id, message.content) {
|
||||||
var animatedContent by remember(message.content) { mutableStateOf(message.content) }
|
mutableStateOf(message.content)
|
||||||
val isAnimating = shouldAnimateMessage(message.id)
|
}
|
||||||
|
|
||||||
// Character-by-character animation state like the JavaScript version
|
// Character-by-character animation state like the JavaScript version
|
||||||
var characterStates by remember(message.content) {
|
var characterStates by remember(message.id, message.content) {
|
||||||
mutableStateOf(message.content.map { char ->
|
mutableStateOf(message.content.map { char ->
|
||||||
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
|
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update animated content when animation state changes
|
LaunchedEffect(message.id, message.content) {
|
||||||
LaunchedEffect(isAnimating, message.content) {
|
if (message.content.isEmpty()) return@LaunchedEffect
|
||||||
if (isAnimating && message.content.isNotEmpty()) {
|
|
||||||
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
|
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
|
||||||
|
|
||||||
// Start character animations with staggered delays (like JS version)
|
// Start character animations with staggered delays (like JS version).
|
||||||
message.content.forEachIndexed { index, targetChar ->
|
message.content.forEachIndexed { index, targetChar ->
|
||||||
if (targetChar != ' ') { // Skip spaces
|
if (targetChar != ' ') {
|
||||||
launch {
|
launch {
|
||||||
delay(index * 50L) // Stagger start like JS version
|
delay(index * 50L)
|
||||||
|
|
||||||
// Animate this character indefinitely in a loop
|
while (true) {
|
||||||
while (true) {
|
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
|
||||||
// Animate with random characters
|
val newContent = animatedContent.toCharArray()
|
||||||
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
|
if (index < newContent.size) {
|
||||||
// Generate random encrypted character for this position
|
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
|
||||||
val newContent = animatedContent.toCharArray()
|
animatedContent = String(newContent)
|
||||||
if (index < newContent.size) {
|
}
|
||||||
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
|
|
||||||
animatedContent = String(newContent)
|
delay(100L)
|
||||||
}
|
|
||||||
|
if (Random.nextFloat() < 0.1f) {
|
||||||
delay(100L) // Change character every 100ms like JS
|
val finalContent = animatedContent.toCharArray()
|
||||||
|
if (index < finalContent.size) {
|
||||||
// Random chance to reveal (10% like JS version)
|
finalContent[index] = targetChar
|
||||||
if (Random.nextFloat() < 0.1f) {
|
animatedContent = String(finalContent)
|
||||||
// Reveal the final character
|
}
|
||||||
val finalContent = animatedContent.toCharArray()
|
|
||||||
if (index < finalContent.size) {
|
val finalStates = characterStates.toMutableList()
|
||||||
finalContent[index] = targetChar
|
finalStates[index] = CharacterAnimationState.FINAL
|
||||||
animatedContent = String(finalContent)
|
characterStates = finalStates
|
||||||
}
|
break
|
||||||
|
|
||||||
// Mark as revealed
|
|
||||||
val finalStates = characterStates.toMutableList()
|
|
||||||
finalStates[index] = CharacterAnimationState.FINAL
|
|
||||||
characterStates = finalStates
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep revealed for 2 seconds, then fade back to encrypted (like JS)
|
|
||||||
delay(2000L)
|
|
||||||
|
|
||||||
// Reset back to encrypted for next cycle
|
|
||||||
val resetStates = characterStates.toMutableList()
|
|
||||||
resetStates[index] = CharacterAnimationState.ENCRYPTED
|
|
||||||
characterStates = resetStates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delay(2000L)
|
||||||
|
|
||||||
|
val resetStates = characterStates.toMutableList()
|
||||||
|
resetStates[index] = CharacterAnimationState.ENCRYPTED
|
||||||
|
characterStates = resetStates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Not animating, show final content
|
|
||||||
animatedContent = message.content
|
|
||||||
characterStates = message.content.map { CharacterAnimationState.FINAL }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary message with animated content for formatting
|
|
||||||
val animatedMessage = message.copy(content = animatedContent)
|
|
||||||
|
|
||||||
// Use formatting function without timestamp during animation
|
|
||||||
val annotatedText = if (isAnimating) {
|
|
||||||
formatMessageAsAnnotatedStringWithoutTimestamp(
|
|
||||||
message = animatedMessage,
|
|
||||||
currentUserNickname = currentUserNickname,
|
|
||||||
meshService = meshService,
|
|
||||||
colorScheme = colorScheme
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
formatMessageAsAnnotatedString(
|
|
||||||
message = animatedMessage,
|
|
||||||
currentUserNickname = currentUserNickname,
|
|
||||||
meshService = meshService,
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
timeFormatter = timeFormatter
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use IDENTICAL Text composable structure as normal message
|
|
||||||
Text(
|
|
||||||
text = annotatedText,
|
|
||||||
modifier = modifier,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
softWrap = true,
|
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Visible,
|
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
|
||||||
color = colorScheme.onSurface
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
TextMessageLayout(
|
||||||
/**
|
|
||||||
* Format message without timestamp and PoW badge for animation phase
|
|
||||||
* Identical to formatMessageAsAnnotatedString but excludes timestamp and PoW badge
|
|
||||||
*/
|
|
||||||
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
|
|
||||||
message: com.bitchat.android.model.BitchatMessage,
|
|
||||||
currentUserNickname: String,
|
|
||||||
meshService: com.bitchat.android.mesh.MeshService,
|
|
||||||
colorScheme: androidx.compose.material3.ColorScheme
|
|
||||||
): AnnotatedString {
|
|
||||||
// Get the full formatted text first
|
|
||||||
val timeFormatter = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
|
|
||||||
val fullText = formatMessageAsAnnotatedString(
|
|
||||||
message = message,
|
message = message,
|
||||||
currentUserNickname = currentUserNickname,
|
currentUserNickname = currentUserNickname,
|
||||||
meshService = meshService,
|
meshService = meshService,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
timeFormatter = timeFormatter
|
timeFormatter = timeFormatter,
|
||||||
|
onNicknameClick = onNicknameClick,
|
||||||
|
onMessageLongPress = onMessageLongPress,
|
||||||
|
modifier = modifier,
|
||||||
|
bodyContent = animatedContent,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Find and remove the timestamp and PoW badge at the end
|
|
||||||
val text = fullText.text
|
|
||||||
val timestampPattern = """ \[\d{2}:\d{2}:\d{2}].*$""".toRegex() // Matches " [HH:mm:ss] 12b" or just " [HH:mm:ss]"
|
|
||||||
val match = timestampPattern.find(text)
|
|
||||||
|
|
||||||
return if (match != null) {
|
|
||||||
// Remove timestamp and PoW portion
|
|
||||||
val endIndex = match.range.first
|
|
||||||
AnnotatedString(
|
|
||||||
text = text.substring(0, endIndex),
|
|
||||||
spanStyles = fullText.spanStyles.filter { it.end <= endIndex },
|
|
||||||
paragraphStyles = fullText.paragraphStyles.filter { it.end <= endIndex }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
fullText
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +1,57 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import com.bitchat.android.model.BitchatMessage
|
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
|
||||||
import com.bitchat.android.mesh.MeshService
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
import com.bitchat.android.ui.media.VoiceNotePlayer
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import com.bitchat.android.ui.media.FileMessageItem
|
|
||||||
import com.bitchat.android.model.BitchatMessageType
|
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
import androidx.compose.ui.res.stringResource
|
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||||
|
import com.bitchat.android.mesh.MeshService
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.model.BitchatMessageType
|
||||||
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
|
import com.bitchat.android.ui.media.FileMessageItem
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
||||||
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
|
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
|
||||||
@ -266,23 +273,21 @@ fun MessageItem(
|
|||||||
timeFormatter = timeFormatter
|
timeFormatter = timeFormatter
|
||||||
)
|
)
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
AnnotatedClickableText(
|
||||||
Text(
|
|
||||||
text = headerText,
|
text = headerText,
|
||||||
|
annotationTags = listOf("nickname_click"),
|
||||||
|
onAnnotationClick = { tag, item ->
|
||||||
|
if (tag == "nickname_click" && onNicknameClick != null) {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onNicknameClick.invoke(item)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
modifier = Modifier.pointerInput(message.id) {
|
|
||||||
detectTapGestures(onTap = { pos ->
|
|
||||||
val layout = headerLayout ?: return@detectTapGestures
|
|
||||||
val offset = layout.getOffsetForPosition(pos)
|
|
||||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
|
||||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
onNicknameClick.invoke(ann.first().item)
|
|
||||||
}
|
|
||||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
|
||||||
},
|
|
||||||
onTextLayout = { headerLayout = it }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try to load the file packet from the path
|
// Try to load the file packet from the path
|
||||||
@ -354,18 +359,16 @@ fun MessageItem(
|
|||||||
// Display message with matrix animation for content
|
// Display message with matrix animation for content
|
||||||
MessageWithMatrixAnimation(
|
MessageWithMatrixAnimation(
|
||||||
message = message,
|
message = message,
|
||||||
messages = messages,
|
|
||||||
currentUserNickname = currentUserNickname,
|
currentUserNickname = currentUserNickname,
|
||||||
meshService = meshService,
|
meshService = meshService,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
timeFormatter = timeFormatter,
|
timeFormatter = timeFormatter,
|
||||||
onNicknameClick = onNicknameClick,
|
onNicknameClick = onNicknameClick,
|
||||||
onMessageLongPress = onMessageLongPress,
|
onMessageLongPress = onMessageLongPress,
|
||||||
onImageClick = onImageClick,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
} else {
|
} else if (message.sender == "system") {
|
||||||
// Normal message display
|
// Keep system messages on the compact legacy line.
|
||||||
val annotatedText = formatMessageAsAnnotatedString(
|
val annotatedText = formatMessageAsAnnotatedString(
|
||||||
message = message,
|
message = message,
|
||||||
currentUserNickname = currentUserNickname,
|
currentUserNickname = currentUserNickname,
|
||||||
@ -373,80 +376,12 @@ fun MessageItem(
|
|||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
timeFormatter = timeFormatter
|
timeFormatter = timeFormatter
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check if this message was sent by self to avoid click interactions on own nickname
|
|
||||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
|
||||||
message.sender == currentUserNickname ||
|
|
||||||
message.sender.startsWith("$currentUserNickname#")
|
|
||||||
|
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val context = LocalContext.current
|
|
||||||
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
|
||||||
Text(
|
Text(
|
||||||
text = annotatedText,
|
text = annotatedText,
|
||||||
modifier = modifier.pointerInput(message) {
|
modifier = modifier.pointerInput(message) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onTap = { position ->
|
|
||||||
val layout = textLayoutResult ?: return@detectTapGestures
|
|
||||||
val offset = layout.getOffsetForPosition(position)
|
|
||||||
// Nickname click only when not self
|
|
||||||
if (!isSelf && onNicknameClick != null) {
|
|
||||||
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
|
||||||
tag = "nickname_click",
|
|
||||||
start = offset,
|
|
||||||
end = offset
|
|
||||||
)
|
|
||||||
if (nicknameAnnotations.isNotEmpty()) {
|
|
||||||
val nickname = nicknameAnnotations.first().item
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
onNicknameClick.invoke(nickname)
|
|
||||||
return@detectTapGestures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Geohash teleport (all messages)
|
|
||||||
val geohashAnnotations = annotatedText.getStringAnnotations(
|
|
||||||
tag = "geohash_click",
|
|
||||||
start = offset,
|
|
||||||
end = offset
|
|
||||||
)
|
|
||||||
if (geohashAnnotations.isNotEmpty()) {
|
|
||||||
val geohash = geohashAnnotations.first().item
|
|
||||||
try {
|
|
||||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
|
||||||
context
|
|
||||||
)
|
|
||||||
val level = when (geohash.length) {
|
|
||||||
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
|
||||||
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
|
||||||
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
|
||||||
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
|
||||||
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
|
||||||
}
|
|
||||||
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
|
||||||
locationManager.setTeleported(true)
|
|
||||||
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
return@detectTapGestures
|
|
||||||
}
|
|
||||||
// URL open (all messages)
|
|
||||||
val urlAnnotations = annotatedText.getStringAnnotations(
|
|
||||||
tag = "url_click",
|
|
||||||
start = offset,
|
|
||||||
end = offset
|
|
||||||
)
|
|
||||||
if (urlAnnotations.isNotEmpty()) {
|
|
||||||
val raw = urlAnnotations.first().item
|
|
||||||
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
|
|
||||||
try {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
return@detectTapGestures
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress = {
|
onLongPress = {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
onMessageLongPress?.invoke(message)
|
onMessageLongPress?.invoke(message)
|
||||||
@ -458,8 +393,129 @@ fun MessageItem(
|
|||||||
overflow = TextOverflow.Visible,
|
overflow = TextOverflow.Visible,
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
style = androidx.compose.ui.text.TextStyle(
|
||||||
color = colorScheme.onSurface
|
color = colorScheme.onSurface
|
||||||
),
|
)
|
||||||
onTextLayout = { result -> textLayoutResult = result }
|
)
|
||||||
|
} else {
|
||||||
|
TextMessageLayout(
|
||||||
|
message = message,
|
||||||
|
currentUserNickname = currentUserNickname,
|
||||||
|
meshService = meshService,
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
timeFormatter = timeFormatter,
|
||||||
|
onNicknameClick = onNicknameClick,
|
||||||
|
onMessageLongPress = onMessageLongPress,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TextMessageLayout(
|
||||||
|
message: BitchatMessage,
|
||||||
|
currentUserNickname: String,
|
||||||
|
meshService: MeshService,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
timeFormatter: SimpleDateFormat,
|
||||||
|
onNicknameClick: ((String) -> Unit)?,
|
||||||
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
bodyContent: String = message.content,
|
||||||
|
) {
|
||||||
|
val myPeerId = meshService.myPeerID
|
||||||
|
val displayMessage = remember(message, bodyContent) {
|
||||||
|
if (bodyContent == message.content) message else message.copy(content = bodyContent)
|
||||||
|
}
|
||||||
|
val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
|
||||||
|
formatTextMessageSender(
|
||||||
|
message = message,
|
||||||
|
currentUserNickname = currentUserNickname,
|
||||||
|
meshService = meshService,
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
|
||||||
|
formatTextMessageMetadata(
|
||||||
|
message = message,
|
||||||
|
timeFormatter = timeFormatter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
|
||||||
|
formatTextMessageBody(
|
||||||
|
message = displayMessage,
|
||||||
|
currentUserNickname = currentUserNickname,
|
||||||
|
meshService = meshService,
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val handleLongPress: () -> Unit = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onMessageLongPress?.invoke(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AnnotatedClickableText(
|
||||||
|
text = senderText,
|
||||||
|
annotationTags = listOf("nickname_click"),
|
||||||
|
onAnnotationClick = { tag, item ->
|
||||||
|
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onNicknameClick.invoke(item)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = handleLongPress,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
softWrap = false,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
AnnotatedClickableText(
|
||||||
|
text = metadataText,
|
||||||
|
annotationTags = emptyList(),
|
||||||
|
onAnnotationClick = { _, _ -> false },
|
||||||
|
onLongPress = handleLongPress,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnnotatedClickableText(
|
||||||
|
text = bodyText,
|
||||||
|
annotationTags = listOf("geohash_click", "url_click"),
|
||||||
|
onAnnotationClick = { tag, item ->
|
||||||
|
when (tag) {
|
||||||
|
"geohash_click" -> {
|
||||||
|
navigateToGeohash(context, item)
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
"url_click" -> {
|
||||||
|
openMessageUrl(context, item)
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = handleLongPress,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
softWrap = true,
|
||||||
|
overflow = TextOverflow.Visible,
|
||||||
|
style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
|
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||||
import com.bitchat.android.mesh.MeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
@ -58,23 +55,21 @@ fun AudioMessageItem(
|
|||||||
timeFormatter = timeFormatter
|
timeFormatter = timeFormatter
|
||||||
)
|
)
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
AnnotatedClickableText(
|
||||||
Text(
|
|
||||||
text = headerText,
|
text = headerText,
|
||||||
|
annotationTags = listOf("nickname_click"),
|
||||||
|
onAnnotationClick = { tag, item ->
|
||||||
|
if (tag == "nickname_click" && onNicknameClick != null) {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onNicknameClick.invoke(item)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
modifier = Modifier.pointerInput(message.id) {
|
|
||||||
detectTapGestures(onTap = { pos ->
|
|
||||||
val layout = headerLayout ?: return@detectTapGestures
|
|
||||||
val offset = layout.getOffsetForPosition(pos)
|
|
||||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
|
||||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
onNicknameClick.invoke(ann.first().item)
|
|
||||||
}
|
|
||||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
|
||||||
},
|
|
||||||
onTextLayout = { headerLayout = it }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|||||||
@ -3,13 +3,11 @@ package com.bitchat.android.ui.media
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -18,21 +16,18 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import com.bitchat.android.mesh.MeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.BitchatMessageType
|
import com.bitchat.android.model.BitchatMessageType
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageMessageItem(
|
fun ImageMessageItem(
|
||||||
@ -58,23 +53,21 @@ fun ImageMessageItem(
|
|||||||
timeFormatter = timeFormatter
|
timeFormatter = timeFormatter
|
||||||
)
|
)
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
AnnotatedClickableText(
|
||||||
Text(
|
|
||||||
text = headerText,
|
text = headerText,
|
||||||
|
annotationTags = listOf("nickname_click"),
|
||||||
|
onAnnotationClick = { tag, item ->
|
||||||
|
if (tag == "nickname_click" && onNicknameClick != null) {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
onNicknameClick.invoke(item)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
modifier = Modifier.pointerInput(message.id) {
|
|
||||||
detectTapGestures(onTap = { pos ->
|
|
||||||
val layout = headerLayout ?: return@detectTapGestures
|
|
||||||
val offset = layout.getOffsetForPosition(pos)
|
|
||||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
|
||||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
||||||
onNicknameClick.invoke(ann.first().item)
|
|
||||||
}
|
|
||||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
|
||||||
},
|
|
||||||
onTextLayout = { headerLayout = it }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">فتح قسم حول</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -387,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">পরিচিতি খুলুন</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -401,4 +401,5 @@
|
|||||||
<string name="verify_success_title">Verifiziert</string>
|
<string name="verify_success_title">Verifiziert</string>
|
||||||
<string name="verify_success_body">Du hast %1$s verifiziert</string>
|
<string name="verify_success_body">Du hast %1$s verifiziert</string>
|
||||||
<string name="verify_success_system_message">verifiziert %1$s</string>
|
<string name="verify_success_system_message">verifiziert %1$s</string>
|
||||||
|
<string name="cd_open_about">Info öffnen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">Verificado</string>
|
<string name="verify_success_title">Verificado</string>
|
||||||
<string name="verify_success_body">Verificaste a %1$s</string>
|
<string name="verify_success_body">Verificaste a %1$s</string>
|
||||||
<string name="verify_success_system_message">verificado %1$s</string>
|
<string name="verify_success_system_message">verificado %1$s</string>
|
||||||
|
<string name="cd_open_about">Abrir Acerca de</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -387,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">باز کردن درباره</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -280,7 +280,6 @@
|
|||||||
<string name="grant_permissions">Payagan</string>
|
<string name="grant_permissions">Payagan</string>
|
||||||
<string name="location_tracking_warning">HINDI sinusubaybayan ng bitchat ang lokasyon mo</string>
|
<string name="location_tracking_warning">HINDI sinusubaybayan ng bitchat ang lokasyon mo</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">TAO</string>
|
<string name="geohash_people_header">TAO</string>
|
||||||
<string name="nobody_around">walang tao sa paligid…</string>
|
<string name="nobody_around">walang tao sa paligid…</string>
|
||||||
@ -400,4 +399,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Buksan ang Tungkol</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -281,7 +281,6 @@
|
|||||||
<string name="grant_permissions">Accorder les autorisations</string>
|
<string name="grant_permissions">Accorder les autorisations</string>
|
||||||
<string name="location_tracking_warning">bitchat ne suit PAS ta position</string>
|
<string name="location_tracking_warning">bitchat ne suit PAS ta position</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">PERSONNES</string>
|
<string name="geohash_people_header">PERSONNES</string>
|
||||||
<string name="nobody_around">personne aux alentours…</string>
|
<string name="nobody_around">personne aux alentours…</string>
|
||||||
@ -414,4 +413,5 @@
|
|||||||
<string name="verify_success_title">Vérifié</string>
|
<string name="verify_success_title">Vérifié</string>
|
||||||
<string name="verify_success_body">Vous avez vérifié %1$s</string>
|
<string name="verify_success_body">Vous avez vérifié %1$s</string>
|
||||||
<string name="verify_success_system_message">vérifié %1$s</string>
|
<string name="verify_success_system_message">vérifié %1$s</string>
|
||||||
|
<string name="cd_open_about">Ouvrir À propos</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -53,5 +53,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">פתיחת אודות</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">परिचय खोलें</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Buka Tentang</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -340,7 +340,6 @@
|
|||||||
|
|
||||||
<!-- Simboli e stringhe speciali -->
|
<!-- Simboli e stringhe speciali -->
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="mention_suggestion_at">@%1$s</string>
|
<string name="mention_suggestion_at">@%1$s</string>
|
||||||
<string name="image_counter">%1$d / %2$d</string>
|
<string name="image_counter">%1$d / %2$d</string>
|
||||||
@ -434,4 +433,5 @@
|
|||||||
<string name="verify_success_title">Verificato</string>
|
<string name="verify_success_title">Verificato</string>
|
||||||
<string name="verify_success_body">Hai verificato %1$s</string>
|
<string name="verify_success_body">Hai verificato %1$s</string>
|
||||||
<string name="verify_success_system_message">verificato %1$s</string>
|
<string name="verify_success_system_message">verificato %1$s</string>
|
||||||
|
<string name="cd_open_about">Apri Informazioni</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">検証済み</string>
|
<string name="verify_success_title">検証済み</string>
|
||||||
<string name="verify_success_body">%1$s を検証しました</string>
|
<string name="verify_success_body">%1$s を検証しました</string>
|
||||||
<string name="verify_success_system_message">%1$s を検証しました</string>
|
<string name="verify_success_system_message">%1$s を検証しました</string>
|
||||||
|
<string name="cd_open_about">このアプリについてを開く</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -387,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">აპის შესახებ გახსნა</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">정보 열기</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -289,7 +289,6 @@
|
|||||||
<string name="grant_permissions">Omeo Alalana</string>
|
<string name="grant_permissions">Omeo Alalana</string>
|
||||||
<string name="location_tracking_warning">Ny bitchat dia TSY manaraka ny toerananao</string>
|
<string name="location_tracking_warning">Ny bitchat dia TSY manaraka ny toerananao</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">OLONA</string>
|
<string name="geohash_people_header">OLONA</string>
|
||||||
<string name="nobody_around">tsy misy olona manodidina...</string>
|
<string name="nobody_around">tsy misy olona manodidina...</string>
|
||||||
@ -414,4 +413,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Sokafy ny momba</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -40,5 +40,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Buka Perihal</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -280,7 +280,6 @@
|
|||||||
<string name="grant_permissions">अनुमति दिनुहोस्</string>
|
<string name="grant_permissions">अनुमति दिनुहोस्</string>
|
||||||
<string name="location_tracking_warning">bitchat ले तपाईंको स्थान पछ्याउँदैन</string>
|
<string name="location_tracking_warning">bitchat ले तपाईंको स्थान पछ्याउँदैन</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">मानिसहरू</string>
|
<string name="geohash_people_header">मानिसहरू</string>
|
||||||
<string name="nobody_around">वरिपरि कोही छैन…</string>
|
<string name="nobody_around">वरिपरि कोही छैन…</string>
|
||||||
@ -400,4 +399,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">परिचय खोल्नुहोस्</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -340,7 +340,6 @@
|
|||||||
|
|
||||||
<!-- Symbolen & speciale strings -->
|
<!-- Symbolen & speciale strings -->
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="mention_suggestion_at">@%1$s</string>
|
<string name="mention_suggestion_at">@%1$s</string>
|
||||||
<string name="image_counter">%1$d / %2$d</string>
|
<string name="image_counter">%1$d / %2$d</string>
|
||||||
@ -432,4 +431,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Info openen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -387,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">ایپ بارے کھولو</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -53,5 +53,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Otwórz informacje</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">Verificado</string>
|
<string name="verify_success_title">Verificado</string>
|
||||||
<string name="verify_success_body">Você verificou %1$s</string>
|
<string name="verify_success_body">Você verificou %1$s</string>
|
||||||
<string name="verify_success_system_message">verificou %1$s</string>
|
<string name="verify_success_system_message">verificou %1$s</string>
|
||||||
|
<string name="cd_open_about">Abrir Sobre</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -264,7 +264,6 @@
|
|||||||
<string name="grant_permissions">Выдать разрешения</string>
|
<string name="grant_permissions">Выдать разрешения</string>
|
||||||
<string name="location_tracking_warning">bitchat НЕ отслеживает вашу геопозицию</string>
|
<string name="location_tracking_warning">bitchat НЕ отслеживает вашу геопозицию</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">ЛЮДИ</string>
|
<string name="geohash_people_header">ЛЮДИ</string>
|
||||||
<string name="nobody_around">никого рядом…</string>
|
<string name="nobody_around">никого рядом…</string>
|
||||||
@ -390,4 +389,5 @@
|
|||||||
<string name="verify_success_title">Проверено</string>
|
<string name="verify_success_title">Проверено</string>
|
||||||
<string name="verify_success_body">Вы проверили %1$s</string>
|
<string name="verify_success_body">Вы проверили %1$s</string>
|
||||||
<string name="verify_success_system_message">проверен %1$s</string>
|
<string name="verify_success_system_message">проверен %1$s</string>
|
||||||
|
<string name="cd_open_about">Открыть раздел «О приложении»</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -264,7 +264,6 @@
|
|||||||
<string name="grant_permissions">Ge behörigheter</string>
|
<string name="grant_permissions">Ge behörigheter</string>
|
||||||
<string name="location_tracking_warning">bitchat spårar INTE din plats</string>
|
<string name="location_tracking_warning">bitchat spårar INTE din plats</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">PERSONER</string>
|
<string name="geohash_people_header">PERSONER</string>
|
||||||
<string name="nobody_around">ingen i närheten…</string>
|
<string name="nobody_around">ingen i närheten…</string>
|
||||||
@ -388,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Öppna Om</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -40,5 +40,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -387,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">เปิดเกี่ยวกับ</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -264,7 +264,6 @@
|
|||||||
<string name="grant_permissions">İzin ver</string>
|
<string name="grant_permissions">İzin ver</string>
|
||||||
<string name="location_tracking_warning">bitchat konumunu takip etmez</string>
|
<string name="location_tracking_warning">bitchat konumunu takip etmez</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">KİŞİLER</string>
|
<string name="geohash_people_header">KİŞİLER</string>
|
||||||
<string name="nobody_around">yakında kimse yok…</string>
|
<string name="nobody_around">yakında kimse yok…</string>
|
||||||
@ -388,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Hakkında’yı aç</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -40,5 +40,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -400,4 +400,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">تعارف کھولیں</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -387,4 +387,5 @@
|
|||||||
<string name="verify_success_title">Verified</string>
|
<string name="verify_success_title">Verified</string>
|
||||||
<string name="verify_success_body">You verified %1$s</string>
|
<string name="verify_success_body">You verified %1$s</string>
|
||||||
<string name="verify_success_system_message">verified %1$s</string>
|
<string name="verify_success_system_message">verified %1$s</string>
|
||||||
|
<string name="cd_open_about">Mở phần Giới thiệu</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -280,7 +280,6 @@
|
|||||||
<string name="grant_permissions">授予权限</string>
|
<string name="grant_permissions">授予权限</string>
|
||||||
<string name="location_tracking_warning">bitchat 不会跟踪你的位置</string>
|
<string name="location_tracking_warning">bitchat 不会跟踪你的位置</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">成员</string>
|
<string name="geohash_people_header">成员</string>
|
||||||
<string name="nobody_around">附近无人…</string>
|
<string name="nobody_around">附近无人…</string>
|
||||||
@ -413,4 +412,5 @@
|
|||||||
<string name="verify_success_title">已验证</string>
|
<string name="verify_success_title">已验证</string>
|
||||||
<string name="verify_success_body">你已验证 %1$s</string>
|
<string name="verify_success_body">你已验证 %1$s</string>
|
||||||
<string name="verify_success_system_message">已验证 %1$s</string>
|
<string name="verify_success_system_message">已验证 %1$s</string>
|
||||||
|
<string name="cd_open_about">打开“关于”</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -371,7 +371,7 @@
|
|||||||
<string name="grant_permissions">Grant Permissions</string>
|
<string name="grant_permissions">Grant Permissions</string>
|
||||||
<string name="location_tracking_warning">bitchat does NOT track your location</string>
|
<string name="location_tracking_warning">bitchat does NOT track your location</string>
|
||||||
<string name="at_symbol">@</string>
|
<string name="at_symbol">@</string>
|
||||||
<string name="app_brand">bitchat/</string>
|
<string name="cd_open_about">Open About</string>
|
||||||
<string name="channel_count_prefix"> · ⧉ </string>
|
<string name="channel_count_prefix"> · ⧉ </string>
|
||||||
<string name="geohash_people_header">PEOPLE</string>
|
<string name="geohash_people_header">PEOPLE</string>
|
||||||
<string name="nobody_around">nobody around...</string>
|
<string name="nobody_around">nobody around...</string>
|
||||||
|
|||||||
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