From 36bb89fd36eedefcbd482d3a2996c6990cb4ed60 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 2 Jan 2026 14:54:24 +0200 Subject: [PATCH] update desktop with refactored classes --- .../amethyst/commons/model/User.kt | 21 +- .../commons/state/EventCollectionState.kt | 232 ++++++++++++++++++ .../amethyst/commons/state/FollowState.kt | 163 ++++++++++++ .../amethyst/commons/state/LoadingState.kt | 109 ++++++++ .../commons/state/UserMetadataState.kt | 57 +++++ .../amethyst/desktop/ui/FeedScreen.kt | 28 ++- .../desktop/ui/NotificationsScreen.kt | 92 +++---- .../amethyst/desktop/ui/UserProfileScreen.kt | 90 ++++--- 8 files changed, 676 insertions(+), 116 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/FollowState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/LoadingState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/UserMetadataState.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index 08e1af1fd..287992db5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -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.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt new file mode 100644 index 000000000..9307da691 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt @@ -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( + * 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( + private val getId: (T) -> String, + private val sortComparator: Comparator? = null, + private val maxSize: Int = 200, + private val batchDelayMs: Long = 250, + private val scope: CoroutineScope, +) { + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val seenIds = mutableSetOf() + private val pendingItems = mutableListOf() + 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) { + 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) { + 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() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/FollowState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/FollowState.kt new file mode 100644 index 000000000..9996f94ac --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/FollowState.kt @@ -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.Idle) + val state: StateFlow> = _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 +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/LoadingState.kt new file mode 100644 index 000000000..fe15f18e9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/LoadingState.kt @@ -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>> = ... + * + * 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 { + /** + * Initial state - operation has not started yet. + * Useful for actions (like follow/unfollow) that haven't been triggered. + */ + object Idle : LoadingState() + + /** + * Operation is in progress. + */ + object Loading : LoadingState() + + /** + * Operation completed successfully with data. + * + * @param data The loaded data + */ + data class Success( + val data: T, + ) : LoadingState() + + /** + * 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() + + /** + * Operation completed successfully but returned no data. + * Useful for feeds/lists that have no items. + */ + object Empty : LoadingState() + + /** + * 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 +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/UserMetadataState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/UserMetadataState.kt new file mode 100644 index 000000000..c19a5aeb5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/UserMetadataState.kt @@ -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, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index a3d5744f5..ef027ebbb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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() } - val seenIds = remember { mutableSetOf() } + val scope = rememberCoroutineScope() + val eventState = + remember { + EventCollectionState( + getId = { it.id }, + sortComparator = compareByDescending { it.createdAt }, + maxSize = 200, + scope = scope, + ) + } + val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } var followedUsers by remember { mutableStateOf>(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?, ) { - 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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 10fb8d247..336496408 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -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() } - val seenIds = remember { mutableSetOf() } + val scope = rememberCoroutineScope() + val notificationState = + remember { + EventCollectionState( + 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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 02fe9ff2c..52ae95e56 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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() } - val seenIds = remember { mutableSetOf() } + val eventState = + remember { + EventCollectionState( + 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(null) } var retryTrigger by remember { mutableStateOf(0) } // Follow state - var isFollowing by remember { mutableStateOf(false) } - var currentContactList by remember { mutableStateOf(null) } - var isFollowLoading by remember { mutableStateOf(false) } - var followError by remember { mutableStateOf(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?, ) { 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?, ) { - 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") } } -}