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.DualCase
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal import java.math.BigDecimal
interface UserDependencies interface UserDependencies
@@ -326,23 +325,9 @@ data class RelayInfo(
var counter: Long, var counter: Long,
) )
@Stable // Re-export from commons.state for backwards compatibility
class UserBundledRefresherFlow( typealias UserBundledRefresherFlow = com.vitorpamplona.amethyst.commons.state.UserMetadataState
val user: User, typealias UserState = com.vitorpamplona.amethyst.commons.state.UserState
) {
val stateFlow = MutableStateFlow(UserState(user))
fun invalidateData() {
stateFlow.tryEmit(UserState(user))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
@Immutable
class UserState(
val user: User,
)
fun Set<User>.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } 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,
)
@@ -45,14 +45,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState 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.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
@@ -108,8 +109,17 @@ fun FeedScreen(
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
val events = remember { mutableStateListOf<Event>() } val scope = rememberCoroutineScope()
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 replyToEvent by remember { mutableStateOf<Event?>(null) } var replyToEvent by remember { mutableStateOf<Event?>(null) }
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) } var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -162,8 +172,7 @@ fun FeedScreen(
val configuredRelays = relayStatuses.keys val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) { if (configuredRelays.isNotEmpty()) {
// Clear previous events when switching modes // Clear previous events when switching modes
events.clear() eventState.clear()
seenIds.clear()
val subId = "${feedMode.name.lowercase()}-feed-${System.currentTimeMillis()}" val subId = "${feedMode.name.lowercase()}-feed-${System.currentTimeMillis()}"
val filters = val filters =
@@ -203,14 +212,7 @@ fun FeedScreen(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
if (event.id !in seenIds) { eventState.addItem(event)
seenIds.add(event.id)
events.add(0, event)
if (events.size > 200) {
val removed = events.removeAt(events.size - 1)
seenIds.remove(removed.id)
}
}
} }
override fun onEose( override fun onEose(
@@ -42,8 +42,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.Reply
import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.commons.icons.Repost
import com.vitorpamplona.amethyst.commons.icons.Zap 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.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.commons.util.toTimeAgo
@@ -109,8 +110,17 @@ fun NotificationsScreen(
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
val notifications = remember { mutableStateListOf<NotificationItem>() } val scope = rememberCoroutineScope()
val seenIds = remember { mutableSetOf<String>() } 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) { DisposableEffect(relayStatuses, account.pubKeyHex) {
val configuredRelays = relayStatuses.keys val configuredRelays = relayStatuses.keys
@@ -150,50 +160,42 @@ fun NotificationsScreen(
return return
} }
if (event.id !in seenIds) { val notification =
seenIds.add(event.id) when (event) {
is ReactionEvent ->
val notification = NotificationItem.Reaction(
when (event) { event = event,
is ReactionEvent -> timestamp = event.createdAt,
NotificationItem.Reaction( content = event.content,
event = event, )
timestamp = event.createdAt, is RepostEvent, is GenericRepostEvent ->
content = event.content, NotificationItem.Repost(
) event = event,
is RepostEvent, is GenericRepostEvent -> timestamp = event.createdAt,
NotificationItem.Repost( )
event = event, is LnZapEvent -> {
timestamp = event.createdAt, // Extract amount from zap (simplified - full parsing in production)
) val amount = event.amount?.toLong()
is LnZapEvent -> { NotificationItem.Zap(
// Extract amount from zap (simplified - full parsing in production) event = event,
val amount = event.amount?.toLong() timestamp = event.createdAt,
NotificationItem.Zap( amount = amount,
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)
} }
is TextNoteEvent -> {
notifications.add(0, notification) // Check if it's a reply (has e-tag) or mention
if (notifications.size > 200) { val eTags = event.tags.filter { it.size > 1 && it[0] == "e" }
val removed = notifications.removeAt(notifications.size - 1) val isReply = eTags.isNotEmpty()
seenIds.remove(removed.event.id) 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( override fun onEose(
@@ -51,7 +51,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -63,6 +62,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.actions.FollowAction 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.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event 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.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
@@ -100,19 +100,28 @@ fun UserProfileScreen(
var followersCount by remember { mutableStateOf(0) } var followersCount by remember { mutableStateOf(0) }
var followingCount by remember { mutableStateOf(0) } var followingCount by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
// User's posts // User's posts
val events = remember { mutableStateListOf<Event>() } val eventState =
val seenIds = remember { mutableSetOf<String>() } 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 postsLoading by remember { mutableStateOf(true) }
var postsError by remember { mutableStateOf<String?>(null) } var postsError by remember { mutableStateOf<String?>(null) }
var retryTrigger by remember { mutableStateOf(0) } var retryTrigger by remember { mutableStateOf(0) }
// Follow state // Follow state
var isFollowing by remember { mutableStateOf(false) } val followState =
var currentContactList by remember { mutableStateOf<ContactListEvent?>(null) } remember(account) {
var isFollowLoading by remember { mutableStateOf(false) } FollowState(myPubKeyHex = account?.pubKeyHex ?: "")
var followError by remember { mutableStateOf<String?>(null) } }
val scope = rememberCoroutineScope()
// Load current user's contact list (for follow state) // Load current user's contact list (for follow state)
DisposableEffect(relayStatuses, account) { DisposableEffect(relayStatuses, account) {
@@ -139,8 +148,7 @@ fun UserProfileScreen(
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
if (event is ContactListEvent) { if (event is ContactListEvent) {
currentContactList = event followState.updateContactList(event, pubKeyHex)
isFollowing = event.isTaggedUser(pubKeyHex)
} }
} }
@@ -165,8 +173,7 @@ fun UserProfileScreen(
if (configuredRelays.isNotEmpty()) { if (configuredRelays.isNotEmpty()) {
postsLoading = true postsLoading = true
postsError = null postsError = null
events.clear() eventState.clear()
seenIds.clear()
// Metadata subscription (kind 0) // Metadata subscription (kind 0)
val metadataSubId = "profile-metadata-$pubKeyHex-${System.currentTimeMillis()}" val metadataSubId = "profile-metadata-$pubKeyHex-${System.currentTimeMillis()}"
relayManager.subscribe( relayManager.subscribe(
@@ -227,14 +234,7 @@ fun UserProfileScreen(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
if (event.id !in seenIds) { eventState.addItem(event)
seenIds.add(event.id)
events.add(0, event)
if (events.size > 100) {
val removed = events.removeAt(events.size - 1)
seenIds.remove(removed.id)
}
}
} }
override fun onEose( override fun onEose(
@@ -292,26 +292,28 @@ fun UserProfileScreen(
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
isFollowLoading = true followState.setFollowLoading()
followError = null
try { try {
if (isFollowing) { val currentStatus = followState.currentStatusOrNull()
unfollowUser(pubKeyHex, account, relayManager, currentContactList) val updatedEvent =
isFollowing = false if (currentStatus?.isFollowing == true) {
} else { unfollowUser(pubKeyHex, account, relayManager, currentStatus.contactList)
followUser(pubKeyHex, account, relayManager, currentContactList) } else {
isFollowing = true followUser(pubKeyHex, account, relayManager, currentStatus?.contactList)
} }
followState.setFollowSuccess(updatedEvent, pubKeyHex)
} catch (e: Exception) { } catch (e: Exception) {
followError = e.message ?: "Failed to update follow status" followState.setFollowError(e.message ?: "Failed to update follow status", e)
} finally {
isFollowLoading = false
} }
} }
}, },
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( androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
strokeWidth = 2.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)) Spacer(Modifier.height(4.dp))
Text( Text(
error, error,
@@ -526,7 +533,7 @@ private suspend fun followUser(
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
currentContactList: ContactListEvent?, currentContactList: ContactListEvent?,
) { ): ContactListEvent =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...") println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...")
@@ -536,8 +543,9 @@ private suspend fun followUser(
println("[UserProfile] ContactListEvent created, broadcasting...") println("[UserProfile] ContactListEvent created, broadcasting...")
relayManager.broadcastToAll(updatedEvent) relayManager.broadcastToAll(updatedEvent)
println("[UserProfile] Follow broadcast complete") println("[UserProfile] Follow broadcast complete")
updatedEvent
} }
}
/** /**
* Unfollows a user by publishing an updated contact list event without them. * Unfollows a user by publishing an updated contact list event without them.
@@ -547,7 +555,7 @@ private suspend fun unfollowUser(
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
currentContactList: ContactListEvent?, currentContactList: ContactListEvent?,
) { ): ContactListEvent =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
println("[UserProfile] Starting unfollowUser: target=${pubKeyHex.take(8)}...") println("[UserProfile] Starting unfollowUser: target=${pubKeyHex.take(8)}...")
@@ -560,8 +568,10 @@ private suspend fun unfollowUser(
println("[UserProfile] ContactListEvent updated, broadcasting...") println("[UserProfile] ContactListEvent updated, broadcasting...")
relayManager.broadcastToAll(updatedEvent) relayManager.broadcastToAll(updatedEvent)
println("[UserProfile] Unfollow broadcast complete") println("[UserProfile] Unfollow broadcast complete")
updatedEvent
} else { } else {
println("[UserProfile] Error: No contact list to unfollow from") println("[UserProfile] Error: No contact list to unfollow from")
throw IllegalStateException("Cannot unfollow: No contact list available")
} }
} }
}