update desktop with refactored classes

This commit is contained in:
nrobi144
2026-01-02 14:54:24 +02:00
parent 2751855186
commit 36bb89fd36
8 changed files with 676 additions and 116 deletions
@@ -42,7 +42,6 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
interface UserDependencies
@@ -326,23 +325,9 @@ data class RelayInfo(
var counter: Long,
)
@Stable
class UserBundledRefresherFlow(
val user: User,
) {
val stateFlow = MutableStateFlow(UserState(user))
fun invalidateData() {
stateFlow.tryEmit(UserState(user))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
@Immutable
class UserState(
val user: User,
)
// Re-export from commons.state for backwards compatibility
typealias UserBundledRefresherFlow = com.vitorpamplona.amethyst.commons.state.UserMetadataState
typealias UserState = com.vitorpamplona.amethyst.commons.state.UserState
fun Set<User>.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex }
@@ -0,0 +1,232 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.state
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Generic event collection state with deduplication, batching, sorting, and size limits.
*
* Provides efficient management of event/item collections with:
* - Automatic deduplication by ID
* - Batched updates (250ms default) to reduce recomposition
* - Optional sorting via comparator
* - Automatic trimming to max size
* - Thread-safe operations
*
* @param T The type of items to collect (must have a unique ID)
* @param getId Function to extract unique ID from an item
* @param sortComparator Optional comparator for sorting items (null = prepend newest first)
* @param maxSize Maximum number of items to keep (older items trimmed)
* @param batchDelayMs Delay in milliseconds before flushing batched updates (default 250ms)
* @param scope CoroutineScope for batching jobs
*
* Usage example:
* ```
* val feedState = EventCollectionState<Event>(
* getId = { it.id },
* sortComparator = compareByDescending { it.createdAt },
* maxSize = 200,
* scope = viewModelScope
* )
*
* // Add items (batched automatically)
* feedState.addItem(event)
* feedState.addItems(eventList)
*
* // Observe
* val items by feedState.items.collectAsState()
* ```
*/
class EventCollectionState<T : Any>(
private val getId: (T) -> String,
private val sortComparator: Comparator<T>? = null,
private val maxSize: Int = 200,
private val batchDelayMs: Long = 250,
private val scope: CoroutineScope,
) {
private val _items = MutableStateFlow<List<T>>(emptyList())
val items: StateFlow<List<T>> = _items.asStateFlow()
private val seenIds = mutableSetOf<String>()
private val pendingItems = mutableListOf<T>()
private val mutex = Mutex()
private var batchJob: Job? = null
/**
* Add a single item to the collection.
* Updates are batched and applied after batchDelayMs.
*
* @param item The item to add
*/
fun addItem(item: T) {
scope.launch {
mutex.withLock {
val itemId = getId(item)
if (itemId !in seenIds) {
pendingItems.add(item)
scheduleBatchUpdate()
}
}
}
}
/**
* Add multiple items to the collection.
* Updates are batched and applied after batchDelayMs.
*
* @param items The items to add
*/
fun addItems(items: List<T>) {
scope.launch {
mutex.withLock {
val newItems = items.filter { getId(it) !in seenIds }
if (newItems.isNotEmpty()) {
pendingItems.addAll(newItems)
scheduleBatchUpdate()
}
}
}
}
/**
* Remove an item by ID.
*
* @param id The ID of the item to remove
*/
fun removeItem(id: String) {
scope.launch {
mutex.withLock {
seenIds.remove(id)
_items.value = _items.value.filter { getId(it) != id }
}
}
}
/**
* Remove multiple items by ID.
*
* @param ids The IDs of items to remove
*/
fun removeItems(ids: Set<String>) {
scope.launch {
mutex.withLock {
seenIds.removeAll(ids)
_items.value = _items.value.filter { getId(it) !in ids }
}
}
}
/**
* Clear all items from the collection.
*/
fun clear() {
scope.launch {
mutex.withLock {
seenIds.clear()
pendingItems.clear()
_items.value = emptyList()
batchJob?.cancel()
batchJob = null
}
}
}
/**
* Get current item count.
*/
val size: Int
get() = _items.value.size
/**
* Check if collection is empty.
*/
val isEmpty: Boolean
get() = _items.value.isEmpty()
/**
* Schedules a batched update if not already scheduled.
* Cancels existing batch job and starts a new one.
*/
private fun scheduleBatchUpdate() {
batchJob?.cancel()
batchJob =
scope.launch {
delay(batchDelayMs)
applyBatchUpdate()
}
}
/**
* Applies pending items to the collection.
* Merges with existing items, sorts if comparator provided, and trims to maxSize.
*/
private suspend fun applyBatchUpdate() {
mutex.withLock {
if (pendingItems.isEmpty()) return
// Add pending IDs to seenIds
pendingItems.forEach { seenIds.add(getId(it)) }
// Merge with existing items
val merged = _items.value + pendingItems
// Sort if comparator provided, otherwise keep newest first (pending items already at end)
val sorted =
if (sortComparator != null) {
merged.sortedWith(sortComparator)
} else {
// Reverse so newest (pending) items come first
(pendingItems.reversed() + _items.value).distinctBy { getId(it) }
}
// Trim to maxSize and update seenIds
val trimmed =
if (sorted.size > maxSize) {
val kept = sorted.take(maxSize)
val removed = sorted.drop(maxSize)
removed.forEach { seenIds.remove(getId(it)) }
kept
} else {
sorted
}
_items.value = trimmed
pendingItems.clear()
}
}
/**
* Force flush pending items immediately without waiting for batch delay.
*/
suspend fun flush() {
batchJob?.cancel()
applyBatchUpdate()
}
}
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.state
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.actions.FollowAction
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Represents the current follow relationship status.
*
* @property isFollowing Whether the target user is currently followed
* @property contactList The current ContactListEvent (NIP-02 kind 3)
*/
@Immutable
data class FollowStatus(
val isFollowing: Boolean,
val contactList: ContactListEvent?,
)
/**
* Reactive state for tracking follow/unfollow status and actions.
*
* Combines current follow status with action state (loading, success, error)
* using LoadingState pattern. Delegates business logic to FollowAction.
*
* Usage:
* ```
* val followState = FollowState(myPubKeyHex)
*
* // Update when contact list arrives from relay
* followState.updateContactList(contactListEvent, targetPubKeyHex)
*
* // Follow action
* followState.setFollowLoading()
* try {
* val updated = FollowAction.follow(targetPubKeyHex, signer, contactList)
* relayManager.broadcast(updated)
* followState.setFollowSuccess(updated, targetPubKeyHex)
* } catch (e: Exception) {
* followState.setFollowError(e.message ?: "Follow failed")
* }
*
* // Observe in UI
* when (val state = followState.state.collectAsState().value) {
* is LoadingState.Idle -> { /* Not loaded yet */ }
* is LoadingState.Loading -> CircularProgressIndicator()
* is LoadingState.Success -> {
* val status = state.data
* if (status.isFollowing) UnfollowButton() else FollowButton()
* }
* is LoadingState.Error -> ErrorMessage(state.message)
* }
* ```
*
* @property myPubKeyHex The current user's public key hex (for context)
*/
@Stable
class FollowState(
private val myPubKeyHex: String,
) {
private val _state = MutableStateFlow<LoadingState<FollowStatus>>(LoadingState.Idle)
val state: StateFlow<LoadingState<FollowStatus>> = _state.asStateFlow()
/**
* Updates the follow status based on a ContactListEvent.
*
* Checks if targetPubKeyHex is in the contact list's p-tags.
*
* @param event The ContactListEvent (NIP-02 kind 3)
* @param targetPubKeyHex The public key of the user to check
*/
fun updateContactList(
event: ContactListEvent,
targetPubKeyHex: String,
) {
val isFollowing = FollowAction.isFollowing(targetPubKeyHex, event)
_state.value = LoadingState.Success(FollowStatus(isFollowing, event))
}
/**
* Sets the state to Loading (follow/unfollow action in progress).
*
* Call this before initiating a follow/unfollow action.
*/
fun setFollowLoading() {
_state.value = LoadingState.Loading
}
/**
* Sets the state to Success with updated follow status.
*
* Call this after successfully broadcasting a follow/unfollow event.
*
* @param newContactList The updated ContactListEvent
* @param targetPubKeyHex The public key of the user that was followed/unfollowed
*/
fun setFollowSuccess(
newContactList: ContactListEvent,
targetPubKeyHex: String,
) {
val isFollowing = FollowAction.isFollowing(targetPubKeyHex, newContactList)
_state.value = LoadingState.Success(FollowStatus(isFollowing, newContactList))
}
/**
* Sets the state to Error.
*
* Call this if follow/unfollow action fails.
*
* @param message The error message
* @param throwable Optional throwable for debugging
*/
fun setFollowError(
message: String,
throwable: Throwable? = null,
) {
_state.value = LoadingState.Error(message, throwable)
}
/**
* Gets the current follow status, if loaded.
*
* @return FollowStatus if state is Success, null otherwise
*/
fun currentStatusOrNull(): FollowStatus? = state.value.dataOrNull()
/**
* Gets the current ContactListEvent, if loaded.
*
* @return ContactListEvent if state is Success, null otherwise
*/
fun currentContactListOrNull(): ContactListEvent? = currentStatusOrNull()?.contactList
/**
* Checks if currently following, if loaded.
*
* @return true if following, false if not following or not loaded
*/
fun isFollowing(): Boolean = currentStatusOrNull()?.isFollowing ?: false
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.state
/**
* Generic loading state representation for async operations.
*
* Provides type-safe state management for loading, success, error, and empty states.
* Eliminates the need for multiple boolean flags (isLoading, hasError, etc.).
*
* @param T The type of data when successfully loaded
*
* Usage example:
* ```
* val feedState: StateFlow<LoadingState<List<Event>>> = ...
*
* when (val state = feedState.collectAsState().value) {
* is LoadingState.Idle -> { /* Not started yet */ }
* is LoadingState.Loading -> CircularProgressIndicator()
* is LoadingState.Success -> LazyColumn { items(state.data) { ... } }
* is LoadingState.Error -> ErrorMessage(state.message)
* is LoadingState.Empty -> EmptyPlaceholder()
* }
* ```
*/
sealed class LoadingState<out T> {
/**
* Initial state - operation has not started yet.
* Useful for actions (like follow/unfollow) that haven't been triggered.
*/
object Idle : LoadingState<Nothing>()
/**
* Operation is in progress.
*/
object Loading : LoadingState<Nothing>()
/**
* Operation completed successfully with data.
*
* @param data The loaded data
*/
data class Success<T>(
val data: T,
) : LoadingState<T>()
/**
* Operation failed with an error.
*
* @param message Human-readable error message
* @param throwable Optional exception for debugging/logging
*/
data class Error(
val message: String,
val throwable: Throwable? = null,
) : LoadingState<Nothing>()
/**
* Operation completed successfully but returned no data.
* Useful for feeds/lists that have no items.
*/
object Empty : LoadingState<Nothing>()
/**
* Returns true if this state represents a successful load (Success or Empty).
*/
val isSuccessful: Boolean
get() = this is Success || this is Empty
/**
* Returns true if this state represents a failure.
*/
val isError: Boolean
get() = this is Error
/**
* Returns true if this state is currently loading.
*/
val isLoading: Boolean
get() = this is Loading
/**
* Returns the data if this is a Success state, null otherwise.
*/
fun dataOrNull(): T? = if (this is Success) data else null
/**
* Returns the error message if this is an Error state, null otherwise.
*/
fun errorOrNull(): String? = if (this is Error) message else null
}
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.state
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.User
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Reactive state wrapper for User metadata.
*
* Provides a StateFlow that emits UserState when user metadata changes.
* Used by both Android ViewModels and Desktop composables for reactive UI updates.
*
* Android pattern: UserBundledRefresherFlow (typealias for backwards compatibility)
*/
@Stable
class UserMetadataState(
val user: User,
) {
val stateFlow = MutableStateFlow(UserState(user))
fun invalidateData() {
stateFlow.tryEmit(UserState(user))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
/**
* Immutable snapshot of User state.
*
* Emitted by UserMetadataState.stateFlow to trigger recomposition.
*/
@Immutable
class UserState(
val user: User,
)