update desktop with refactored classes
This commit is contained in:
@@ -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 }
|
||||
|
||||
|
||||
+232
@@ -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()
|
||||
}
|
||||
}
|
||||
+163
@@ -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
|
||||
}
|
||||
+109
@@ -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
|
||||
}
|
||||
+57
@@ -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,
|
||||
)
|
||||
@@ -45,14 +45,15 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.account.AccountState
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
|
||||
@@ -108,8 +109,17 @@ fun FeedScreen(
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val events = remember { mutableStateListOf<Event>() }
|
||||
val seenIds = remember { mutableSetOf<String>() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val eventState =
|
||||
remember {
|
||||
EventCollectionState<Event>(
|
||||
getId = { it.id },
|
||||
sortComparator = compareByDescending { it.createdAt },
|
||||
maxSize = 200,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
val events by eventState.items.collectAsState()
|
||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
|
||||
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
@@ -162,8 +172,7 @@ fun FeedScreen(
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
// Clear previous events when switching modes
|
||||
events.clear()
|
||||
seenIds.clear()
|
||||
eventState.clear()
|
||||
|
||||
val subId = "${feedMode.name.lowercase()}-feed-${System.currentTimeMillis()}"
|
||||
val filters =
|
||||
@@ -203,14 +212,7 @@ fun FeedScreen(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event.id !in seenIds) {
|
||||
seenIds.add(event.id)
|
||||
events.add(0, event)
|
||||
if (events.size > 200) {
|
||||
val removed = events.removeAt(events.size - 1)
|
||||
seenIds.remove(removed.id)
|
||||
}
|
||||
}
|
||||
eventState.addItem(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
|
||||
+47
-45
@@ -42,8 +42,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.account.AccountState
|
||||
import com.vitorpamplona.amethyst.commons.icons.Reply
|
||||
import com.vitorpamplona.amethyst.commons.icons.Repost
|
||||
import com.vitorpamplona.amethyst.commons.icons.Zap
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
@@ -109,8 +110,17 @@ fun NotificationsScreen(
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val notifications = remember { mutableStateListOf<NotificationItem>() }
|
||||
val seenIds = remember { mutableSetOf<String>() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val notificationState =
|
||||
remember {
|
||||
EventCollectionState<NotificationItem>(
|
||||
getId = { it.event.id },
|
||||
sortComparator = null, // Prepend new items (no sorting)
|
||||
maxSize = 200,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
val notifications by notificationState.items.collectAsState()
|
||||
|
||||
DisposableEffect(relayStatuses, account.pubKeyHex) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
@@ -150,50 +160,42 @@ fun NotificationsScreen(
|
||||
return
|
||||
}
|
||||
|
||||
if (event.id !in seenIds) {
|
||||
seenIds.add(event.id)
|
||||
|
||||
val notification =
|
||||
when (event) {
|
||||
is ReactionEvent ->
|
||||
NotificationItem.Reaction(
|
||||
event = event,
|
||||
timestamp = event.createdAt,
|
||||
content = event.content,
|
||||
)
|
||||
is RepostEvent, is GenericRepostEvent ->
|
||||
NotificationItem.Repost(
|
||||
event = event,
|
||||
timestamp = event.createdAt,
|
||||
)
|
||||
is LnZapEvent -> {
|
||||
// Extract amount from zap (simplified - full parsing in production)
|
||||
val amount = event.amount?.toLong()
|
||||
NotificationItem.Zap(
|
||||
event = event,
|
||||
timestamp = event.createdAt,
|
||||
amount = amount,
|
||||
)
|
||||
}
|
||||
is TextNoteEvent -> {
|
||||
// Check if it's a reply (has e-tag) or mention
|
||||
val eTags = event.tags.filter { it.size > 1 && it[0] == "e" }
|
||||
val isReply = eTags.isNotEmpty()
|
||||
if (isReply) {
|
||||
NotificationItem.Reply(event, event.createdAt)
|
||||
} else {
|
||||
NotificationItem.Mention(event, event.createdAt)
|
||||
}
|
||||
}
|
||||
else -> NotificationItem.Mention(event, event.createdAt)
|
||||
val notification =
|
||||
when (event) {
|
||||
is ReactionEvent ->
|
||||
NotificationItem.Reaction(
|
||||
event = event,
|
||||
timestamp = event.createdAt,
|
||||
content = event.content,
|
||||
)
|
||||
is RepostEvent, is GenericRepostEvent ->
|
||||
NotificationItem.Repost(
|
||||
event = event,
|
||||
timestamp = event.createdAt,
|
||||
)
|
||||
is LnZapEvent -> {
|
||||
// Extract amount from zap (simplified - full parsing in production)
|
||||
val amount = event.amount?.toLong()
|
||||
NotificationItem.Zap(
|
||||
event = event,
|
||||
timestamp = event.createdAt,
|
||||
amount = amount,
|
||||
)
|
||||
}
|
||||
|
||||
notifications.add(0, notification)
|
||||
if (notifications.size > 200) {
|
||||
val removed = notifications.removeAt(notifications.size - 1)
|
||||
seenIds.remove(removed.event.id)
|
||||
is TextNoteEvent -> {
|
||||
// Check if it's a reply (has e-tag) or mention
|
||||
val eTags = event.tags.filter { it.size > 1 && it[0] == "e" }
|
||||
val isReply = eTags.isNotEmpty()
|
||||
if (isReply) {
|
||||
NotificationItem.Reply(event, event.createdAt)
|
||||
} else {
|
||||
NotificationItem.Mention(event, event.createdAt)
|
||||
}
|
||||
}
|
||||
else -> NotificationItem.Mention(event, event.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
notificationState.addItem(notification)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
|
||||
+50
-40
@@ -51,7 +51,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -63,6 +62,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.account.AccountState
|
||||
import com.vitorpamplona.amethyst.commons.actions.FollowAction
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.state.FollowState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -70,7 +71,6 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
@@ -100,19 +100,28 @@ fun UserProfileScreen(
|
||||
var followersCount by remember { mutableStateOf(0) }
|
||||
var followingCount by remember { mutableStateOf(0) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// User's posts
|
||||
val events = remember { mutableStateListOf<Event>() }
|
||||
val seenIds = remember { mutableSetOf<String>() }
|
||||
val eventState =
|
||||
remember {
|
||||
EventCollectionState<Event>(
|
||||
getId = { it.id },
|
||||
sortComparator = compareByDescending { it.createdAt },
|
||||
maxSize = 200,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
val events by eventState.items.collectAsState()
|
||||
var postsLoading by remember { mutableStateOf(true) }
|
||||
var postsError by remember { mutableStateOf<String?>(null) }
|
||||
var retryTrigger by remember { mutableStateOf(0) }
|
||||
|
||||
// Follow state
|
||||
var isFollowing by remember { mutableStateOf(false) }
|
||||
var currentContactList by remember { mutableStateOf<ContactListEvent?>(null) }
|
||||
var isFollowLoading by remember { mutableStateOf(false) }
|
||||
var followError by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val followState =
|
||||
remember(account) {
|
||||
FollowState(myPubKeyHex = account?.pubKeyHex ?: "")
|
||||
}
|
||||
|
||||
// Load current user's contact list (for follow state)
|
||||
DisposableEffect(relayStatuses, account) {
|
||||
@@ -139,8 +148,7 @@ fun UserProfileScreen(
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event is ContactListEvent) {
|
||||
currentContactList = event
|
||||
isFollowing = event.isTaggedUser(pubKeyHex)
|
||||
followState.updateContactList(event, pubKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,8 +173,7 @@ fun UserProfileScreen(
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
postsLoading = true
|
||||
postsError = null
|
||||
events.clear()
|
||||
seenIds.clear()
|
||||
eventState.clear()
|
||||
// Metadata subscription (kind 0)
|
||||
val metadataSubId = "profile-metadata-$pubKeyHex-${System.currentTimeMillis()}"
|
||||
relayManager.subscribe(
|
||||
@@ -227,14 +234,7 @@ fun UserProfileScreen(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event.id !in seenIds) {
|
||||
seenIds.add(event.id)
|
||||
events.add(0, event)
|
||||
if (events.size > 100) {
|
||||
val removed = events.removeAt(events.size - 1)
|
||||
seenIds.remove(removed.id)
|
||||
}
|
||||
}
|
||||
eventState.addItem(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
@@ -292,26 +292,28 @@ fun UserProfileScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isFollowLoading = true
|
||||
followError = null
|
||||
followState.setFollowLoading()
|
||||
try {
|
||||
if (isFollowing) {
|
||||
unfollowUser(pubKeyHex, account, relayManager, currentContactList)
|
||||
isFollowing = false
|
||||
} else {
|
||||
followUser(pubKeyHex, account, relayManager, currentContactList)
|
||||
isFollowing = true
|
||||
}
|
||||
val currentStatus = followState.currentStatusOrNull()
|
||||
val updatedEvent =
|
||||
if (currentStatus?.isFollowing == true) {
|
||||
unfollowUser(pubKeyHex, account, relayManager, currentStatus.contactList)
|
||||
} else {
|
||||
followUser(pubKeyHex, account, relayManager, currentStatus?.contactList)
|
||||
}
|
||||
followState.setFollowSuccess(updatedEvent, pubKeyHex)
|
||||
} catch (e: Exception) {
|
||||
followError = e.message ?: "Failed to update follow status"
|
||||
} finally {
|
||||
isFollowLoading = false
|
||||
followState.setFollowError(e.message ?: "Failed to update follow status", e)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isFollowLoading,
|
||||
enabled = followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
|
||||
) {
|
||||
if (isFollowLoading) {
|
||||
val state = followState.state.collectAsState().value
|
||||
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
|
||||
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
|
||||
|
||||
if (isLoading) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
@@ -330,7 +332,12 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
followError?.let { error ->
|
||||
val errorMessage =
|
||||
followState.state
|
||||
.collectAsState()
|
||||
.value
|
||||
.errorOrNull()
|
||||
errorMessage?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
error,
|
||||
@@ -526,7 +533,7 @@ private suspend fun followUser(
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
currentContactList: ContactListEvent?,
|
||||
) {
|
||||
): ContactListEvent =
|
||||
withContext(Dispatchers.IO) {
|
||||
println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...")
|
||||
|
||||
@@ -536,8 +543,9 @@ private suspend fun followUser(
|
||||
println("[UserProfile] ContactListEvent created, broadcasting...")
|
||||
relayManager.broadcastToAll(updatedEvent)
|
||||
println("[UserProfile] Follow broadcast complete")
|
||||
|
||||
updatedEvent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfollows a user by publishing an updated contact list event without them.
|
||||
@@ -547,7 +555,7 @@ private suspend fun unfollowUser(
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
currentContactList: ContactListEvent?,
|
||||
) {
|
||||
): ContactListEvent =
|
||||
withContext(Dispatchers.IO) {
|
||||
println("[UserProfile] Starting unfollowUser: target=${pubKeyHex.take(8)}...")
|
||||
|
||||
@@ -560,8 +568,10 @@ private suspend fun unfollowUser(
|
||||
println("[UserProfile] ContactListEvent updated, broadcasting...")
|
||||
relayManager.broadcastToAll(updatedEvent)
|
||||
println("[UserProfile] Unfollow broadcast complete")
|
||||
|
||||
updatedEvent
|
||||
} else {
|
||||
println("[UserProfile] Error: No contact list to unfollow from")
|
||||
throw IllegalStateException("Cannot unfollow: No contact list available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user