From 8c92f12300f246b46b8aa6c781d2075d48d53000 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 30 Aug 2025 15:17:38 +0200 Subject: [PATCH] matrix style --- .../android/nostr/NostrGeohashService.kt | 52 +++- .../android/ui/MatrixEncryptionAnimation.kt | 275 ++++++++++++++++++ .../bitchat/android/ui/MessageComponents.kt | 193 ++++++------ 3 files changed, 419 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index 1ef98ce6..88d5c762 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -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()}") } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt new file mode 100644 index 00000000..2c9c86b1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -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) -> 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>(emptySet()) + val miningMessages: StateFlow> = _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 +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index a27bbdad..c8452c3b 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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(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(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