matrix style

This commit is contained in:
callebtc
2025-08-30 15:17:38 +02:00
parent c14762f82e
commit 8c92f12300
3 changed files with 419 additions and 101 deletions
@@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
import kotlin.random.Random
/**
* Service responsible for all Nostr and Geohash business logic extracted from ChatViewModel
@@ -242,6 +243,35 @@ class NostrGeohashService(
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
coroutineScope.launch {
try {
// Generate a temporary message ID for tracking animation
val tempMessageId = "temp_${System.currentTimeMillis()}_${Random.nextInt(1000)}"
// Add local echo message IMMEDIATELY (with temporary ID)
val localMessage = BitchatMessage(
id = tempMessageId,
sender = nickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = "geohash:${channel.geohash}",
channel = "#${channel.geohash}"
)
// Store and display the message immediately
storeGeohashMessage(channel.geohash, localMessage)
messageManager.addMessage(localMessage)
Log.d(TAG, "📝 Added message immediately with temp ID: $tempMessageId")
// Check if PoW is enabled before starting animation
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {
// Start matrix animation for this message
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempMessageId)
Log.d(TAG, "🎭 Started matrix animation for message: $tempMessageId")
}
// Now begin the async PoW process
val identity = NostrIdentityBridge.deriveIdentity(
forGeohash = channel.geohash,
context = application
@@ -257,6 +287,12 @@ class NostrGeohashService(
teleported = teleported
)
// Stop animation when PoW completes
if (powSettings.enabled && powSettings.difficulty > 0) {
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempMessageId)
Log.d(TAG, "🎭 Stopped matrix animation for message: $tempMessageId")
}
val nostrRelayManager = NostrRelayManager.getInstance(application)
nostrRelayManager.sendEventToGeohash(
event = event,
@@ -267,22 +303,10 @@ class NostrGeohashService(
Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}")
// Add local echo message
val localMessage = BitchatMessage(
sender = nickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = "geohash:${channel.geohash}",
channel = "#${channel.geohash}"
)
// Store our own message in geohash history
storeGeohashMessage(channel.geohash, localMessage)
messageManager.addMessage(localMessage)
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash message: ${e.message}")
// Make sure to stop animation even if there's an error
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage("temp_${System.currentTimeMillis()}")
}
}
}
@@ -0,0 +1,275 @@
package com.bitchat.android.ui
import androidx.compose.animation.core.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.TextUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.random.Random
/**
* Matrix-style encryption animation for messages during PoW computation
* Animates message content with random characters that gradually resolve to real text
*/
@Composable
fun AnimatedMatrixText(
targetText: String,
isAnimating: Boolean,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontWeight: FontWeight? = null
) {
// Animation state for each character
var animatedText by remember(targetText) { mutableStateOf(targetText) }
var characterStates by remember(targetText) { mutableStateOf(
targetText.indices.map { CharacterAnimationState.ENCRYPTED }.toList()
) }
// Start animation when isAnimating becomes true
LaunchedEffect(isAnimating, targetText) {
if (isAnimating && targetText.isNotEmpty()) {
// Reset character states for new animation
characterStates = targetText.indices.map { CharacterAnimationState.ENCRYPTED }.toList()
// Animate all characters in parallel with staggered start times
targetText.forEachIndexed { index, targetChar ->
launch { // Use the LaunchedEffect's CoroutineScope
// Stagger character animations (50ms per character like the JS example)
delay(index * 50L)
animateCharacter(
index = index,
targetChar = targetChar,
currentText = { animatedText },
updateText = { newText -> animatedText = newText },
updateState = { newStates -> characterStates = newStates }
)
}
}
} else {
// Not animating, show final text immediately
animatedText = targetText
characterStates = targetText.indices.map { CharacterAnimationState.FINAL }.toList()
}
}
// Build annotated string with proper styling for each character state
val styledText = buildAnnotatedString {
animatedText.forEachIndexed { index, char ->
val state = characterStates.getOrNull(index) ?: CharacterAnimationState.FINAL
val charColor = when (state) {
CharacterAnimationState.ENCRYPTED -> Color(0xFF00FF41) // Matrix green
CharacterAnimationState.DECRYPTING -> Color(0xFFFFFF41) // Yellow transition
CharacterAnimationState.FINAL -> color
}
withStyle(
style = SpanStyle(
color = charColor,
fontFamily = FontFamily.Monospace,
fontWeight = if (state == CharacterAnimationState.ENCRYPTED) FontWeight.Bold else fontWeight
)
) {
append(char)
}
}
}
Text(
text = styledText,
modifier = modifier,
fontSize = fontSize,
fontFamily = FontFamily.Monospace
)
}
/**
* Animation state for individual characters
*/
private enum class CharacterAnimationState {
ENCRYPTED, // Showing random encrypted characters
DECRYPTING, // Transitioning to final character
FINAL // Showing final decrypted character
}
/**
* Animate a single character from encrypted to final state
*/
private suspend fun animateCharacter(
index: Int,
targetChar: Char,
currentText: () -> String,
updateText: (String) -> Unit,
updateState: (List<CharacterAnimationState>) -> Unit
) {
val encryptedChars = "!@#$%^&*()_+-=[]{}|;:,.<>?~`".toCharArray()
val animationDuration = Random.nextLong(1000, 3000) // 1-3 seconds of encryption
val startTime = System.currentTimeMillis()
// Phase 1: Animate with random encrypted characters
while (System.currentTimeMillis() - startTime < animationDuration) {
val randomChar = encryptedChars[Random.nextInt(encryptedChars.size)]
// Update the character at this index
val text = currentText()
if (index < text.length) {
val newText = text.toCharArray()
newText[index] = randomChar
updateText(String(newText))
}
delay(100) // Change character every 100ms
}
// Phase 2: Brief transition state (yellow)
val states = currentText().indices.map { i ->
when {
i == index -> CharacterAnimationState.DECRYPTING
i < index -> CharacterAnimationState.FINAL // Already revealed
else -> CharacterAnimationState.ENCRYPTED // Still encrypted
}
}
updateState(states)
delay(200) // Brief yellow flash
// Phase 3: Reveal final character
val text = currentText()
if (index < text.length) {
val newText = text.toCharArray()
newText[index] = targetChar
updateText(String(newText))
val finalStates = currentText().indices.map { i ->
when {
i <= index -> CharacterAnimationState.FINAL
else -> CharacterAnimationState.ENCRYPTED
}
}
updateState(finalStates)
}
}
/**
* Check if a message should be animated based on its mining state
*/
@Composable
fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsState()
return miningMessages.contains(messageId)
}
/**
* Tracks which messages are currently being mined for PoW
* Provides reactive state for UI animations
*/
object PoWMiningTracker {
private val _miningMessages = MutableStateFlow<Set<String>>(emptySet())
val miningMessages: StateFlow<Set<String>> = _miningMessages.asStateFlow()
/**
* Start tracking a message as mining
*/
fun startMiningMessage(messageId: String) {
_miningMessages.value = _miningMessages.value + messageId
}
/**
* Stop tracking a message as mining
*/
fun stopMiningMessage(messageId: String) {
_miningMessages.value = _miningMessages.value - messageId
}
/**
* Check if a message is currently mining
*/
fun isMiningMessage(messageId: String): Boolean {
return _miningMessages.value.contains(messageId)
}
/**
* Clear all mining messages (for cleanup)
*/
fun clearAllMining() {
_miningMessages.value = emptySet()
}
}
/**
* Enhanced message display that shows matrix animation during PoW mining
* This version integrates with the full message display system
*/
@Composable
fun MessageWithMatrixAnimation(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
colorScheme: androidx.compose.material3.ColorScheme,
timeFormatter: java.text.SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
) {
// For simplicity, just animate the entire message content
val baseAnnotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
// Use matrix animation only for the content
AnimatedMatrixText(
targetText = message.content,
isAnimating = shouldAnimateMessage(message.id),
color = colorScheme.onSurface,
fontSize = TextUnit.Unspecified,
fontWeight = if (message.sender == currentUserNickname || message.sender.startsWith("$currentUserNickname#")) {
FontWeight.Bold
} else {
null
},
modifier = modifier
)
}
/**
* Find the start index of the message content (after timestamp and sender)
*/
private fun findContentStartIndex(messageText: String): Int {
// Look for pattern like "] <@sender> " to find where content starts
val pattern = """] <[^>]+> """.toRegex()
val match = pattern.find(messageText)
return match?.range?.last?.plus(1) ?: -1
}
/**
* Find the end index of the message content (before final timestamp)
*/
private fun findContentEndIndex(messageText: String): Int {
// Look for pattern like " [HH:mm:ss]" at the end
val pattern = """ \[\d{2}:\d{2}:\d{2}]$""".toRegex()
val match = pattern.find(messageText)
return match?.range?.first ?: messageText.length
}
@@ -166,101 +166,120 @@ private fun MessageTextWithClickableNicknames(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
) {
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
// Check if this message should be animated during PoW mining
val shouldAnimate = shouldAnimateMessage(message.id)
// Check if this message was sent by self to avoid click interactions on own nickname
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedText,
modifier = modifier.pointerInput(message) {
detectTapGestures(
onTap = { position ->
val layout = textLayoutResult ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(position)
// Nickname click only when not self
if (!isSelf && onNicknameClick != null) {
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
// If animation is needed, use the matrix animation component for content only
if (shouldAnimate) {
// Display message with matrix animation for content
MessageWithMatrixAnimation(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
)
} else {
// Normal message display
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
// Check if this message was sent by self to avoid click interactions on own nickname
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedText,
modifier = modifier.pointerInput(message) {
detectTapGestures(
onTap = { position ->
val layout = textLayoutResult ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(position)
// Nickname click only when not self
if (!isSelf && onNicknameClick != null) {
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
start = offset,
end = offset
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(nickname)
return@detectTapGestures
}
}
// Geohash teleport (all messages)
val geohashAnnotations = annotatedText.getStringAnnotations(
tag = "geohash_click",
start = offset,
end = offset
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
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)
onNicknameClick.invoke(nickname)
return@detectTapGestures
}
// URL open (all messages)
val urlAnnotations = annotatedText.getStringAnnotations(
tag = "url_click",
start = offset,
end = offset
)
if (urlAnnotations.isNotEmpty()) {
val raw = urlAnnotations.first().item
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
// Geohash teleport (all messages)
val geohashAnnotations = annotatedText.getStringAnnotations(
tag = "geohash_click",
start = offset,
end = offset
)
if (geohashAnnotations.isNotEmpty()) {
val geohash = geohashAnnotations.first().item
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
context
)
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
locationManager.setTeleported(true)
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
// URL open (all messages)
val urlAnnotations = annotatedText.getStringAnnotations(
tag = "url_click",
start = offset,
end = offset
)
if (urlAnnotations.isNotEmpty()) {
val raw = urlAnnotations.first().item
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
)
},
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
),
onTextLayout = { result -> textLayoutResult = result }
)
)
},
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
),
onTextLayout = { result -> textLayoutResult = result }
)
}
}
@Composable