update desktop with refactored classes

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