Files
bitchat-android/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt
T
e96330e50b Migrate from LiveData to Kotlin Flow (#518)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Chore: Remove unused `lifecycle-livedata-ktx` dependency

This commit removes the `androidx.lifecycle:lifecycle-livedata-ktx` library from the project's dependencies.

The `[libraries]` and `[bundles]` sections in `gradle/libs.versions.toml` have been updated to reflect this removal, as the dependency is no longer in use.

* Refactor: Remove unused `runtime-livedata` dependency

* Refactor: Migrate `LocationChannelManager` and `GeohashBookmarksStore` to StateFlow

This commit refactors `LocationChannelManager` and `GeohashBookmarksStore` to use `StateFlow` instead of `LiveData` for managing and exposing their state. This change aligns with modern Android development practices and improves testability.

**Key Changes:**

- **`LocationChannelManager`**:
    - All `MutableLiveData` properties (`permissionState`, `availableChannels`, `selectedChannel`, etc.) have been replaced with `MutableStateFlow`.
    - Consumers now access these properties as `StateFlow`.
    - State updates have been changed from `postValue()` to direct `.value` assignments, simplifying thread management within the manager which already uses a dedicated coroutine scope.

- **`GeohashBookmarksStore`**:
    - `bookmarks` and `bookmarkNames` are now exposed as `StateFlow` instead of `LiveData`.
    - State updates similarly use `.value` assignment.

- **Nullability**:
    - The non-nullable nature of `StateFlow`'s value reduces the need for null-checks in both the manager classes and their consumers, leading to safer code.

* Refactor: Migrate from LiveData to StateFlow for Nostr components

This commit replaces `LiveData` with `StateFlow` across core Nostr-related classes to align with modern Android architecture and improve state management. This change affects `NostrClient`, `NostrRelayManager`, `LocationNotesManager`, and `GeohashRepository`.

**Key Changes:**

-   **`NostrClient`**:
    -   `isInitialized` and `currentNpub` are now `StateFlow` instead of `LiveData`.
    -   `relayConnectionStatus` and `relayInfo` now return `StateFlow` from `NostrRelayManager`.

-   **`NostrRelayManager`**:
    -   Public properties `relays` and `isConnected` are migrated from `MutableLiveData` to `MutableStateFlow`.
    -   Updates are now pushed using `.value` instead of `.postValue()`.

-   **`LocationNotesManager`**:
    -   All public `LiveData` properties (`notes`, `geohash`, `initialLoadComplete`, `state`, `errorMessage`) are converted to `StateFlow`.
    -   The class documentation is updated to reflect the use of `StateFlow`.

-   **`GeohashRepository`**:
    -   Methods `updateGeohashPeople` and `updateReactiveParticipantCounts` now call `set...` methods on the `state` object instead of `post...`, reflecting the removal of `LiveData` from the underlying state management.

* Refactor: Migrate ChatState from LiveData to StateFlow

This commit refactors the `ChatState`, `ChatViewModel`, and `GeohashViewModel` to use `StateFlow` instead of `LiveData` for managing and exposing UI state. This migration improves state management by leveraging modern coroutine-based flows.

**Key Changes:**

- **`ChatState.kt`**:
    - Replaced all `MutableLiveData` instances with `MutableStateFlow`.
    - Exposed state properties as `StateFlow` instead of `LiveData`.
    - Removed `MediatorLiveData` for computed properties (`hasUnreadChannels`, `hasUnreadPrivateMessages`) and replaced them with `Flow.combine` to create derivative `StateFlows`.
    - Simplified non-nullable `getters` to directly return the `.value` of the `StateFlows`.
    - Removed `postValue` helpers that are no longer necessary.

- **`ChatViewModel.kt`**:
    - Updated all state properties to be `StateFlow`, reflecting the changes in `ChatState`.

- **`GeohashViewModel.kt`**:
    - Changed state properties (`geohashPeople`, `geohashParticipantCounts`, etc.) from `LiveData` to `StateFlow`.
    - Replaced `observeForever` on `LiveData` from `LocationChannelManager` with `viewModelScope.launch` blocks that `.collect()` from the underlying flows.

* Refactor: Migrate UI from LiveData to StateFlow

This commit replaces `LiveData.observeAsState()` with `StateFlow.collectAsState()` across various UI components. This change aligns the codebase with modern Android development practices, using Kotlin Flows for reactive UI state management.

No functional changes are intended. The primary goal is to remove the dependency on `androidx.lifecycle.livedata` from the composable functions.

**Affected Components:**
- `ChatScreen`
- `SidebarComponents`
- `ChatHeader`
- `LocationChannelsSheet`
- `LocationNotesSheet`
- `LocationNotesButton`
- `GeohashPeopleList`
- `LocationNotesSheetPresenter`

* Refactor: Use `collectAsStateWithLifecycle` for UI state collection

* Refactor: move CloseButton to core/ui/component

* Refactor: remove AI generated comments

* Refactor: fix combine to map and use WhileSubscribed

* Refactor: Pass CoroutineScope to ChatState

This commit refactors the `ChatState` class to accept a `CoroutineScope` in its constructor instead of creating its own.

**Key Changes:**

- **`ChatState.kt`**: The constructor now requires a `CoroutineScope`. This scope is used for the `stateIn` operators that convert `Flows` into `StateFlows` (`hasUnreadChannels`, `hasUnreadPrivateMessages`), ensuring they operate within the lifecycle of the provided scope.
- **`ChatViewModel.kt`**: The `viewModelScope` is now passed to the `ChatState` constructor during its instantiation. This ties the lifecycle of the state's coroutines directly to the `ViewModel`'s lifecycle.

* Test: Use `TestScope` for coroutines in `CommandProcessorTest`

This commit refactors `CommandProcessorTest` to use a `TestScope` and `UnconfinedTestDispatcher` for managing coroutines.

This ensures that coroutine-based operations within the test are executed in a controlled and predictable manner, improving test reliability. The `coroutineScope` for `CommandProcessor` and the `scope` for `ChatState` are now both configured to use this test-specific scope.

---------

Co-authored-by: GitHub Action <action@github.com>
2025-12-13 15:58:48 +07:00

274 lines
10 KiB
Kotlin

package com.bitchat.android.ui
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import 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
/**
* Animation state for individual characters
*/
private enum class CharacterAnimationState {
ENCRYPTED, // Showing random encrypted characters
DECRYPTING, // Transitioning to final character
FINAL // Showing final decrypted character
}
/**
* Check if a message should be animated based on its mining state
*/
@Composable
fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
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
* Formats message like a normal message but animates only the content portion
*/
@Composable
fun MessageWithMatrixAnimation(
message: com.bitchat.android.model.BitchatMessage,
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
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)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
) {
val isAnimating = shouldAnimateMessage(message.id)
if (isAnimating) {
// During animation: Show formatted message with animated content
AnimatedMessageDisplay(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
modifier = modifier
)
} else {
// After animation: Show complete normal message using existing formatter
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
Text(
text = annotatedText,
modifier = modifier,
fontFamily = FontFamily.Monospace,
softWrap = true
)
}
}
/**
* Display message with proper formatting but animated content
* Uses IDENTICAL layout structure as normal message for pixel-perfect alignment
*/
@Composable
private fun AnimatedMessageDisplay(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
colorScheme: androidx.compose.material3.ColorScheme,
timeFormatter: java.text.SimpleDateFormat,
modifier: Modifier = Modifier
) {
// Get the animated content text
var animatedContent by remember(message.content) { mutableStateOf(message.content) }
val isAnimating = shouldAnimateMessage(message.id)
// Character-by-character animation state like the JavaScript version
var characterStates by remember(message.content) {
mutableStateOf(message.content.map { char ->
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
})
}
// Update animated content when animation state changes
LaunchedEffect(isAnimating, message.content) {
if (isAnimating && message.content.isNotEmpty()) {
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
// Start character animations with staggered delays (like JS version)
message.content.forEachIndexed { index, targetChar ->
if (targetChar != ' ') { // Skip spaces
launch {
delay(index * 50L) // Stagger start like JS version
// Animate this character indefinitely in a loop
while (true) {
// Animate with random characters
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
// Generate random encrypted character for this position
val newContent = animatedContent.toCharArray()
if (index < newContent.size) {
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
animatedContent = String(newContent)
}
delay(100L) // Change character every 100ms like JS
// Random chance to reveal (10% like JS version)
if (Random.nextFloat() < 0.1f) {
// Reveal the final character
val finalContent = animatedContent.toCharArray()
if (index < finalContent.size) {
finalContent[index] = targetChar
animatedContent = String(finalContent)
}
// Mark as revealed
val finalStates = characterStates.toMutableList()
finalStates[index] = CharacterAnimationState.FINAL
characterStates = finalStates
break
}
}
// 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
}
}
}
}
} else {
// Not animating, show final content
animatedContent = message.content
characterStates = message.content.map { CharacterAnimationState.FINAL }
}
}
// Create a temporary message with animated content for formatting
val animatedMessage = message.copy(content = animatedContent)
// Use formatting function without timestamp during animation
val annotatedText = if (isAnimating) {
formatMessageAsAnnotatedStringWithoutTimestamp(
message = animatedMessage,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme
)
} else {
formatMessageAsAnnotatedString(
message = animatedMessage,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
}
// Use IDENTICAL Text composable structure as normal message
Text(
text = annotatedText,
modifier = modifier,
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = androidx.compose.ui.text.style.TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
)
)
}
/**
* Format message without timestamp and PoW badge for animation phase
* Identical to formatMessageAsAnnotatedString but excludes timestamp and PoW badge
*/
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
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,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
// 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
}
}