Merge pull request #1663 from nrobi144/phase1-final

Desktop - Threads and Notifications + Empty state updates
This commit is contained in:
Vitor Pamplona
2026-01-12 10:00:20 -05:00
committed by GitHub
82 changed files with 1304 additions and 341 deletions
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedFilter
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -174,7 +174,7 @@ class ThreadDualAxisChartAssemblerTest {
null, null,
) )
val filter = ThreadFeedFilter(account, naddr.toTag()) val filter = ThreadFeedFilter(account, naddr.toTag(), LocalCache)
val calculatedFeed = filter.feed() val calculatedFeed = filter.feed()
val expectedOrder = val expectedOrder =
@@ -1662,7 +1662,7 @@ class Account(
fun isHidden(userHex: String): Boolean = hiddenUsers.flow.value.isUserHidden(userHex) fun isHidden(userHex: String): Boolean = hiddenUsers.flow.value.isUserHidden(userHex)
fun followingKeySet(): Set<HexKey> = kind3FollowList.flow.value.authors override fun followingKeySet(): Set<HexKey> = kind3FollowList.flow.value.authors
fun isAcceptable(user: User): Boolean { fun isAcceptable(user: User): Boolean {
if (userProfile().pubkeyHex == user.pubkeyHex) { if (userProfile().pubkeyHex == user.pubkeyHex) {
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache import android.util.LruCache
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.IChannel
import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
@@ -212,7 +214,7 @@ interface ILocalCache {
) {} ) {}
} }
object LocalCache : ILocalCache { object LocalCache : ILocalCache, ICacheProvider {
val antiSpam = AntiSpamFilter() val antiSpam = AntiSpamFilter()
val users = LargeSoftCache<HexKey, User>() val users = LargeSoftCache<HexKey, User>()
@@ -317,16 +319,35 @@ object LocalCache : ILocalCache {
} }
} }
fun getUserIfExists(key: String): User? { override fun getUserIfExists(key: String): User? {
if (key.isEmpty()) return null if (key.isEmpty()) return null
return users.get(key) return users.get(key)
} }
override fun countUsers(predicate: (String, Any) -> Boolean): Int {
var count = 0
users.forEach { key, user ->
if (predicate(key, user)) count++
}
return count
}
override fun getAnyChannel(note: Any?): IChannel? {
val channelNote = note as? Note ?: return null
val channel = getAnyChannel(channelNote)
// Wrap Channel to implement IChannel interface
return channel?.let {
object : IChannel {
override fun relays(): List<Any>? = it.relays().toList()
}
}
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) } fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) }
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) } override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) }
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
@@ -357,7 +378,7 @@ object LocalCache : ILocalCache {
return null return null
} }
fun checkGetOrCreateNote(key: String): Note? { override fun checkGetOrCreateNote(key: String): Note? {
if (ATag.isATag(key)) { if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key) return checkGetOrCreateAddressableNote(key)
} }
@@ -382,6 +403,19 @@ object LocalCache : ILocalCache {
return null return null
} }
override fun getEventStream(): com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream =
object : com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream {
override val newEventBundles = live.newEventBundles
override val deletedEventBundles = live.deletedEventBundles
}
override fun hasBeenDeleted(event: Any): Boolean =
if (event is Event) {
deletionIndex.hasBeenDeleted(event)
} else {
false
}
fun getOrAddAliasNote( fun getOrAddAliasNote(
idHex: String, idHex: String,
note: Note, note: Note,
@@ -0,0 +1,27 @@
/**
* 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.service
// Re-export from commons for backwards compatibility
typealias BundledUpdate = com.vitorpamplona.amethyst.commons.service.BundledUpdate
typealias BasicBundledUpdate = com.vitorpamplona.amethyst.commons.service.BasicBundledUpdate
typealias BundledInsert<T> = com.vitorpamplona.amethyst.commons.service.BundledInsert<T>
typealias BasicBundledInsert<T> = com.vitorpamplona.amethyst.commons.service.BasicBundledInsert<T>
@@ -0,0 +1,27 @@
/**
* 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.ui.dal
// Re-export from commons for backwards compatibility
typealias IFeedFilter<T> = com.vitorpamplona.amethyst.commons.ui.feeds.IFeedFilter<T>
typealias IAdditiveFeedFilter<T> = com.vitorpamplona.amethyst.commons.ui.feeds.IAdditiveFeedFilter<T>
typealias FeedFilter<T> = com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter<T>
typealias AdditiveFeedFilter<T> = com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter<T>
@@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -0,0 +1,32 @@
/**
* 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.ui.feeds
// Re-export from commons for backwards compatibility - import everything
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState as CommonsFeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState as CommonsFeedState
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent as CommonsInvalidatableContent
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState as CommonsLoadedFeedState
typealias FeedState = CommonsFeedState
typealias LoadedFeedState<T> = CommonsLoadedFeedState<T>
typealias InvalidatableContent = CommonsInvalidatableContent
typealias FeedContentState = CommonsFeedContentState
@@ -28,12 +28,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
@@ -20,52 +20,17 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable // Re-export from commons for backwards compatibility
abstract class FeedViewModel( typealias FeedViewModel = com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
/**
* Android-specific FeedViewModel base class that provides LocalCache as the cache provider.
* Subclasses can extend this to automatically use LocalCache without passing cacheProvider.
*/
abstract class AndroidFeedViewModel(
localFilter: FeedFilter<Note>, localFilter: FeedFilter<Note>,
) : ViewModel(), ) : FeedViewModel(localFilter, LocalCache)
InvalidatableContent {
val feedState = FeedContentState(localFilter, viewModelScope)
override val isRefreshing = feedState.isRefreshing
fun sendToTop() = feedState.sendToTop()
suspend fun sentToTop() = feedState.sentToTop()
override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing)
init {
Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}")
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
Log.d("Rendering Metrics", "Update feeds: ${this@FeedViewModel.javaClass.simpleName} with ${newNotes.size}")
feedState.updateFeedWith(newNotes)
}
}
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect { newNotes ->
Log.d("Rendering Metrics", "Delete from feeds: ${this@FeedViewModel.javaClass.simpleName} with ${newNotes.size}")
feedState.deleteFromFeed(newNotes)
}
}
}
override fun onCleared() {
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
super.onCleared()
}
}
@@ -25,12 +25,12 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.BundledUpdate
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -20,11 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
@@ -50,28 +51,28 @@ class AccountFeedContentStates(
val scope: CoroutineScope, val scope: CoroutineScope,
) { ) {
val homeLive = ChannelFeedContentState(HomeLiveFilter(account), scope) val homeLive = ChannelFeedContentState(HomeLiveFilter(account), scope)
val homeNewThreads = FeedContentState(HomeNewThreadFeedFilter(account), scope) val homeNewThreads = FeedContentState(HomeNewThreadFeedFilter(account), scope, LocalCache)
val homeReplies = FeedContentState(HomeConversationsFeedFilter(account), scope) val homeReplies = FeedContentState(HomeConversationsFeedFilter(account), scope, LocalCache)
val dmKnown = FeedContentState(ChatroomListKnownFeedFilter(account), scope) val dmKnown = FeedContentState(ChatroomListKnownFeedFilter(account), scope, LocalCache)
val dmNew = FeedContentState(ChatroomListNewFeedFilter(account), scope) val dmNew = FeedContentState(ChatroomListNewFeedFilter(account), scope, LocalCache)
val videoFeed = FeedContentState(VideoFeedFilter(account), scope) val videoFeed = FeedContentState(VideoFeedFilter(account), scope, LocalCache)
val discoverFollowSets = FeedContentState(DiscoverFollowSetsFeedFilter(account), scope) val discoverFollowSets = FeedContentState(DiscoverFollowSetsFeedFilter(account), scope, LocalCache)
val discoverReads = FeedContentState(DiscoverLongFormFeedFilter(account), scope) val discoverReads = FeedContentState(DiscoverLongFormFeedFilter(account), scope, LocalCache)
val discoverMarketplace = FeedContentState(DiscoverMarketplaceFeedFilter(account), scope) val discoverMarketplace = FeedContentState(DiscoverMarketplaceFeedFilter(account), scope, LocalCache)
val discoverDVMs = FeedContentState(DiscoverNIP89FeedFilter(account), scope) val discoverDVMs = FeedContentState(DiscoverNIP89FeedFilter(account), scope, LocalCache)
val discoverLive = FeedContentState(DiscoverLiveFeedFilter(account), scope) val discoverLive = FeedContentState(DiscoverLiveFeedFilter(account), scope, LocalCache)
val discoverCommunities = FeedContentState(DiscoverCommunityFeedFilter(account), scope) val discoverCommunities = FeedContentState(DiscoverCommunityFeedFilter(account), scope, LocalCache)
val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope) val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
val notificationSummary = NotificationSummaryState(account) val notificationSummary = NotificationSummaryState(account)
val feedListOptions = TopNavFilterState(account, scope) val feedListOptions = TopNavFilterState(account, scope)
val drafts = FeedContentState(DraftEventsFeedFilter(account), scope) val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache)
suspend fun init() { suspend fun init() {
notificationSummary.initializeSuspend() notificationSummary.initializeSuspend()
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
@@ -70,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
@@ -24,12 +24,12 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
@Stable @Stable
class BookmarkPrivateFeedViewModel( class BookmarkPrivateFeedViewModel(
val account: Account, val account: Account,
) : FeedViewModel(BookmarkPrivateFeedFilter(account)) { ) : AndroidFeedViewModel(BookmarkPrivateFeedFilter(account)) {
class Factory( class Factory(
val account: Account, val account: Account,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@@ -24,12 +24,12 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
@Stable @Stable
class BookmarkPublicFeedViewModel( class BookmarkPublicFeedViewModel(
val account: Account, val account: Account,
) : FeedViewModel(BookmarkPublicFeedFilter(account)) { ) : AndroidFeedViewModel(BookmarkPublicFeedFilter(account)) {
class Factory( class Factory(
val account: Account, val account: Account,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@@ -30,12 +30,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
@@ -24,12 +24,13 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ListChange import com.vitorpamplona.amethyst.model.ListChange
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -53,7 +54,7 @@ abstract class ListChangeFeedViewModel(
localFilter: ChangesFlowFilter<Note>, localFilter: ChangesFlowFilter<Note>,
) : ViewModel(), ) : ViewModel(),
InvalidatableContent { InvalidatableContent {
val feedState = FeedContentState(localFilter, viewModelScope) val feedState = FeedContentState(localFilter, viewModelScope, LocalCache)
override val isRefreshing = feedState.isRefreshing override val isRefreshing = feedState.isRefreshing
@@ -31,11 +31,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
@@ -47,7 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -27,7 +27,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -29,7 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -36,8 +36,8 @@ import com.google.accompanist.adaptive.FoldAwareConfiguration
import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy
import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.TwoPane
import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class CommunityFeedViewModel( class CommunityFeedViewModel(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
) : FeedViewModel(CommunityFeedFilter(note, account)) { ) : AndroidFeedViewModel(CommunityFeedFilter(note, account)) {
class Factory( class Factory(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class CommunityModerationFeedViewModel( class CommunityModerationFeedViewModel(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
) : FeedViewModel(CommunityModerationFeedFilter(note, account)) { ) : AndroidFeedViewModel(CommunityModerationFeedFilter(note, account)) {
class Factory( class Factory(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
@@ -55,11 +55,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -46,9 +46,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteContainer import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteContainer
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys.DRAFTS
@@ -24,14 +24,14 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
@Stable @Stable
class NIP90ContentDiscoveryFeedViewModel( class NIP90ContentDiscoveryFeedViewModel(
val account: Account, val account: Account,
dvmKey: String, dvmKey: String,
requestId: String, requestId: String,
) : FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmKey, requestId)) { ) : AndroidFeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmKey, requestId)) {
class Factory( class Factory(
val account: Account, val account: Account,
val dvmKey: String, val dvmKey: String,
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class FollowPackFeedConversationsFeedViewModel( class FollowPackFeedConversationsFeedViewModel(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
) : FeedViewModel(FollowPackFeedConversationsFeedFilter(note, account)) { ) : AndroidFeedViewModel(FollowPackFeedConversationsFeedFilter(note, account)) {
class Factory( class Factory(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class FollowPackFeedNewThreadFeedViewModel( class FollowPackFeedNewThreadFeedViewModel(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
) : FeedViewModel(FollowPackFeedNewThreadFeedFilter(note, account)) { ) : AndroidFeedViewModel(FollowPackFeedNewThreadFeedFilter(note, account)) {
class Factory( class Factory(
val note: AddressableNote, val note: AddressableNote,
val account: Account, val account: Account,
@@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable @Stable
@@ -33,7 +33,7 @@ class GeoHashFeedViewModel(
val geohash: String, val geohash: String,
val relays: Set<NormalizedRelayUrl>, val relays: Set<NormalizedRelayUrl>,
val account: Account, val account: Account,
) : FeedViewModel( ) : AndroidFeedViewModel(
GeoHashFeedFilter(geohash, relays, account, LocalCache), GeoHashFeedFilter(geohash, relays, account, LocalCache),
) { ) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable @Stable
@@ -33,7 +33,7 @@ class HashtagFeedViewModel(
val hashtag: String, val hashtag: String,
val relays: Set<NormalizedRelayUrl>, val relays: Set<NormalizedRelayUrl>,
val account: Account, val account: Account,
) : FeedViewModel( ) : AndroidFeedViewModel(
HashtagFeedFilter(hashtag, relays, account, LocalCache), HashtagFeedFilter(hashtag, relays, account, LocalCache),
) { ) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -55,6 +55,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
@@ -63,8 +65,6 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
@@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
@@ -35,8 +37,6 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrderCard import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrderCard
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.ui.feeds.LoadedFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
@@ -460,20 +460,6 @@ class CardFeedContentState(
} }
} }
fun <T> equalImmutableLists(
list1: ImmutableList<T>,
list2: ImmutableList<T>,
): Boolean {
if (list1 === list2) return true
if (list1.size != list2.size) return false
for (i in 0 until list1.size) {
if (list1[i] !== list2[i]) {
return false
}
}
return true
}
@Immutable @Immutable
data class CombinedZap( data class CombinedZap(
val request: Note, val request: Note,
@@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.ui.feeds.LoadedFeedState
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -0,0 +1,31 @@
/**
* 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.ui.screen.loggedIn.notifications
import kotlinx.collections.immutable.ImmutableList
// Re-export from commons for backwards compatibility
fun <T> equalImmutableLists(
list1: ImmutableList<T>,
list2: ImmutableList<T>,
): Boolean =
com.vitorpamplona.amethyst.commons.utils
.equalImmutableLists(list1, list2)
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class UserProfileBookmarksFeedViewModel( class UserProfileBookmarksFeedViewModel(
val user: User, val user: User,
val account: Account, val account: Account,
) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) { ) : AndroidFeedViewModel(UserProfileBookmarksFeedFilter(user, account)) {
class Factory( class Factory(
val user: User, val user: User,
val account: Account, val account: Account,
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class UserProfileConversationsFeedViewModel( class UserProfileConversationsFeedViewModel(
val user: User, val user: User,
val account: Account, val account: Account,
) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) { ) : AndroidFeedViewModel(UserProfileConversationsFeedFilter(user, account)) {
class Factory( class Factory(
val user: User, val user: User,
val account: Account, val account: Account,
@@ -33,10 +33,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class UserProfileGalleryFeedViewModel( class UserProfileGalleryFeedViewModel(
val user: User, val user: User,
val account: Account, val account: Account,
) : FeedViewModel(UserProfileGalleryFeedFilter(user, account)) { ) : AndroidFeedViewModel(UserProfileGalleryFeedFilter(user, account)) {
class Factory( class Factory(
val user: User, val user: User,
val account: Account, val account: Account,
@@ -34,8 +34,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class UserAppRecommendationsFeedViewModel( class UserAppRecommendationsFeedViewModel(
val user: User, val user: User,
) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { ) : AndroidFeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) {
class Factory( class Factory(
val user: User, val user: User,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class UserProfileMutualFeedViewModel( class UserProfileMutualFeedViewModel(
val user: User, val user: User,
val account: Account, val account: Account,
) : FeedViewModel(UserProfileMutualFeedFilter(user, account)) { ) : AndroidFeedViewModel(UserProfileMutualFeedFilter(user, account)) {
class Factory( class Factory(
val user: User, val user: User,
val account: Account, val account: Account,
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class UserProfileNewThreadsFeedViewModel( class UserProfileNewThreadsFeedViewModel(
val user: User, val user: User,
val account: Account, val account: Account,
) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) { ) : AndroidFeedViewModel(UserProfileNewThreadFeedFilter(user, account)) {
class Factory( class Factory(
val user: User, val user: User,
val account: Account, val account: Account,
@@ -25,9 +25,9 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
class UserProfileReportFeedViewModel( class UserProfileReportFeedViewModel(
val user: User, val user: User,
) : FeedViewModel(UserProfileReportsFeedFilter(user)) { ) : AndroidFeedViewModel(UserProfileReportsFeedFilter(user)) {
class Factory( class Factory(
val user: User, val user: User,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@@ -29,11 +29,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -25,11 +25,11 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.BundledUpdate
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -48,7 +48,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -56,8 +55,6 @@ import androidx.compose.runtime.remember
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.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -74,6 +71,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
@@ -83,7 +82,6 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -360,34 +358,6 @@ fun RenderThreadFeed(
} }
} }
// Creates a Zebra pattern where each bar is a reply level.
fun Modifier.drawReplyLevel(
level: State<Int>,
color: Color,
selected: Color,
): Modifier =
this
.drawBehind {
val paddingDp = 2
val strokeWidthDp = 2
val levelWidthDp = strokeWidthDp + 1
val padding = paddingDp.dp.toPx()
val strokeWidth = strokeWidthDp.dp.toPx()
val levelWidth = levelWidthDp.dp.toPx()
repeat(level.value) {
this.drawLine(
if (it == level.value - 1) selected else color,
Offset(padding + it * levelWidth, 0f),
Offset(padding + it * levelWidth, size.height),
strokeWidth = strokeWidth,
)
}
return@drawBehind
}.padding(start = (2 + (level.value * 3)).dp)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun NoteMaster( fun NoteMaster(
@@ -20,76 +20,17 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal
import androidx.compose.foundation.interaction.DragInteraction import com.vitorpamplona.amethyst.model.LocalCache
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadLevelCalculator
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
abstract class LevelFeedViewModel( // Re-export from commons for backwards compatibility
typealias LevelFeedViewModel = com.vitorpamplona.amethyst.commons.viewmodels.thread.LevelFeedViewModel
/**
* Android-specific LevelFeedViewModel base class that provides LocalCache as the cache provider.
* Subclasses can extend this to automatically use LocalCache without passing cacheProvider.
*/
abstract class AndroidLevelFeedViewModel(
localFilter: FeedFilter<Note>, localFilter: FeedFilter<Note>,
) : FeedViewModel(localFilter) { ) : LevelFeedViewModel(localFilter, LocalCache)
var llState: LazyListState by mutableStateOf(LazyListState(0, 0))
val hasDragged = mutableStateOf(false)
val selectedIDHex =
llState.interactionSource.interactions
.onEach {
if (it is DragInteraction.Start) {
hasDragged.value = true
}
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null,
)
@OptIn(ExperimentalCoroutinesApi::class)
val levelCacheFlow: StateFlow<Map<Note, Int>> =
feedState.feedContent
.transformLatest { feed ->
emitAll(
if (feed is FeedState.Loaded) {
feed.feed.map {
val cache = mutableMapOf<Note, Int>()
it.list.forEach {
ThreadLevelCalculator.replyLevel(it, cache)
}
cache
}
} else {
MutableStateFlow(mapOf())
},
)
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
mapOf(),
)
fun levelFlowForItem(note: Note) =
levelCacheFlow
.map {
it[note] ?: 0
}.distinctUntilChanged()
}
@@ -22,12 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
class ThreadFeedViewModel( class ThreadFeedViewModel(
account: Account, account: Account,
noteId: String, noteId: String,
) : LevelFeedViewModel(ThreadFeedFilter(account, noteId)) { ) : com.vitorpamplona.amethyst.commons.viewmodels.thread.LevelFeedViewModel(ThreadFeedFilter(account, noteId, LocalCache), LocalCache) {
class Factory( class Factory(
val account: Account, val account: Account,
val noteId: String, val noteId: String,
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress
@@ -53,9 +53,10 @@ fun filterMissingEventsForThread(
val missingAddresses = val missingAddresses =
mapOfSet { mapOfSet {
if (threadInfo.root.event == null && threadInfo.root is AddressableNote) { val rootNote = threadInfo.root
potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> if (rootNote.event == null && rootNote is AddressableNote) {
add(relayUrl, threadInfo.root.address) potentialRelaysToFindEvent(rootNote).ifEmpty { defaultRelays }.forEach { relayUrl ->
add(relayUrl, rootNote.address)
} }
} }
@@ -20,7 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState
@@ -45,7 +46,7 @@ class ThreadEventLoaderSubAssembler(
key: ThreadQueryState, key: ThreadQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? { ): List<RelayBasedFilter>? {
val branches = ThreadAssembler().findThreadFor(key.eventId) ?: return null val branches = ThreadAssembler(LocalCache).findThreadFor(key.eventId) ?: return null
val defaultRelays = key.account.followPlusAllMineWithSearch.flow.value val defaultRelays = key.account.followPlusAllMineWithSearch.flow.value
return filterMissingEventsForThread(branches, defaultRelays) return filterMissingEventsForThread(branches, defaultRelays)
} }
@@ -20,7 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState
@@ -42,7 +43,7 @@ class ThreadFilterSubAssembler(
key: ThreadQueryState, key: ThreadQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? { ): List<RelayBasedFilter>? {
val root = ThreadAssembler().findRoot(key.eventId) ?: return null val root = ThreadAssembler(LocalCache).findRoot(key.eventId) ?: return null
return filterEventsInThreadForRoot(root, since) return filterEventsInThreadForRoot(root, since)
} }
@@ -51,14 +51,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
+4
View File
@@ -67,6 +67,10 @@ kotlin {
implementation(compose.materialIconsExtended) implementation(compose.materialIconsExtended)
implementation(compose.components.uiToolingPreview) implementation(compose.components.uiToolingPreview)
// Lifecycle ViewModel (KMP since 2.8.0)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
// Image loading (Coil 3 - KMP) // Image loading (Coil 3 - KMP)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.okhttp) implementation(libs.coil.okhttp)
@@ -0,0 +1,33 @@
/**
* 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.model
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
actual fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
@@ -0,0 +1,25 @@
/**
* 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.utils
// Debug flag for commons module - kept false to avoid application dependencies
// Application-level modules (amethyst, desktopApp) can implement their own debug timing
actual val isDebug: Boolean = false
@@ -81,4 +81,7 @@ interface IAccount {
val hiddenWordsCase: List<DualCase> val hiddenWordsCase: List<DualCase>
val hiddenUsersHashCodes: Set<Int> val hiddenUsersHashCodes: Set<Int>
val spammersHashCodes: Set<Int> val spammersHashCodes: Set<Int>
/** Set of followed user pubkeys (for feed ordering/highlighting) */
fun followingKeySet(): Set<String>
} }
@@ -18,10 +18,11 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -29,7 +30,9 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
class ThreadAssembler { class ThreadAssembler(
private val cache: ICacheProvider,
) {
private fun searchRoot( private fun searchRoot(
note: Note, note: Note,
testedNotes: MutableSet<Note> = mutableSetOf(), testedNotes: MutableSet<Note> = mutableSetOf(),
@@ -48,9 +51,10 @@ class ThreadAssembler {
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" } ?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1) ?.getOrNull(1)
if (markedAsRoot != null) { if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies // Check to see if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note
return LocalCache.checkGetOrCreateNote(markedAsRoot) if (rootNote?.replyTo?.isEmpty() == true) {
return cache.checkGetOrCreateNote(markedAsRoot) as? Note
} }
} }
@@ -84,7 +88,7 @@ class ThreadAssembler {
) )
fun findRoot(noteId: String): Note? { fun findRoot(noteId: String): Note? {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null val note = cache.checkGetOrCreateNote(noteId) as? Note ?: return null
return if (note.event != null) { return if (note.event != null) {
val thread = OnlyLatestVersionSet() val thread = OnlyLatestVersionSet()
@@ -98,7 +102,7 @@ class ThreadAssembler {
fun findThreadFor(noteId: String): ThreadInfo? { fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread() checkNotInMainThread()
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null val note = cache.checkGetOrCreateNote(noteId) as? Note ?: return null
return if (note.event != null) { return if (note.event != null) {
val thread = OnlyLatestVersionSet() val thread = OnlyLatestVersionSet()
@@ -18,15 +18,12 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import java.lang.Long.min import kotlin.math.min
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class LevelSignature( data class LevelSignature(
val signature: String, val signature: String,
@@ -34,15 +31,13 @@ data class LevelSignature(
val author: User?, val author: User?,
) )
/**
* Platform-specific date-time formatter for thread signatures.
* Returns formatted timestamp in pattern "uuuu-MM-dd-HH:mm:ss"
*/
expect fun formattedDateTime(timestamp: Long): String
object ThreadLevelCalculator { object ThreadLevelCalculator {
val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
private fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
/** /**
* This method caches signatures during each execution to avoid recalculation in longer threads * This method caches signatures during each execution to avoid recalculation in longer threads
*/ */
@@ -0,0 +1,48 @@
/**
* 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.model.cache
import com.vitorpamplona.amethyst.commons.model.Note
import kotlinx.coroutines.flow.SharedFlow
/**
* Event stream interface for cache updates.
*
* Abstracts the real-time event notification system used by ViewModels
* to react to new notes and deletions. Platform implementations
* (Android LocalCache, Desktop cache) provide these streams.
*
* ViewModels collect these flows to incrementally update feed state
* without full refresh.
*/
interface ICacheEventStream {
/**
* Flow of new note bundles added to the cache.
* Emits sets of Note objects when new events arrive from relays.
*/
val newEventBundles: SharedFlow<Set<Note>>
/**
* Flow of deleted note bundles removed from the cache.
* Emits sets of Note objects when deletion events are processed.
*/
val deletedEventBundles: SharedFlow<Set<Note>>
}
@@ -61,6 +61,41 @@ interface ICacheProvider {
* @return Count of users matching the predicate * @return Count of users matching the predicate
*/ */
fun countUsers(predicate: (String, Any) -> Boolean): Int fun countUsers(predicate: (String, Any) -> Boolean): Int
/**
* Gets a Note if it exists in cache.
* Used by ThreadAssembler for finding existing notes.
*
* @param hexKey The note's ID in hex format
* @return The Note if exists in cache, null otherwise
*/
fun getNoteIfExists(hexKey: HexKey): Any?
/**
* Gets an existing Note or creates a new one if it doesn't exist.
* Used by ThreadAssembler for building thread structures.
*
* @param hexKey The note's ID in hex format
* @return The Note (existing or newly created)
*/
fun checkGetOrCreateNote(hexKey: HexKey): Any?
/**
* Gets the event stream for cache updates.
* Used by ViewModels to react to new notes and deletions.
*
* @return The event stream interface
*/
fun getEventStream(): ICacheEventStream
/**
* Checks if an event has been deleted via NIP-09 deletion events.
* Used by feed state to filter out deleted notes.
*
* @param event The event to check
* @return true if the event has been deleted, false otherwise
*/
fun hasBeenDeleted(event: Any): Boolean
} }
/** /**
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.commons.service
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -30,9 +30,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
/** This class is designed to have a waiting time between two calls of invalidate */ /** This class is designed to have a waiting time between two calls of invalidate */
class BundledUpdate( class BundledUpdate(
@@ -65,21 +65,25 @@ class BasicBundledUpdate(
val dispatcher: CoroutineDispatcher = Dispatchers.IO, val dispatcher: CoroutineDispatcher = Dispatchers.IO,
val scope: CoroutineScope, val scope: CoroutineScope,
) { ) {
private var onlyOneInBlock = AtomicBoolean() private val mutex = Mutex()
private var isProcessing = false
private var invalidatesAgain = false private var invalidatesAgain = false
fun invalidate( fun invalidate(
ignoreIfDoing: Boolean = false, ignoreIfDoing: Boolean = false,
onUpdate: suspend () -> Unit, onUpdate: suspend () -> Unit,
) { ) {
if (onlyOneInBlock.getAndSet(true)) {
if (!ignoreIfDoing) {
invalidatesAgain = true
}
return
}
scope.launch(dispatcher) { scope.launch(dispatcher) {
mutex.withLock {
if (isProcessing) {
if (!ignoreIfDoing) {
invalidatesAgain = true
}
return@launch
}
isProcessing = true
}
try { try {
onUpdate() onUpdate()
delay(delay) delay(delay)
@@ -88,8 +92,10 @@ class BasicBundledUpdate(
} }
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
invalidatesAgain = false mutex.withLock {
onlyOneInBlock.set(false) invalidatesAgain = false
isProcessing = false
}
} }
} }
} }
@@ -127,37 +133,44 @@ class BasicBundledInsert<T>(
val dispatcher: CoroutineDispatcher = Dispatchers.IO, val dispatcher: CoroutineDispatcher = Dispatchers.IO,
val scope: CoroutineScope, val scope: CoroutineScope,
) { ) {
private var onlyOneInBlock = AtomicBoolean() private val mutex = Mutex()
private var queue = LinkedBlockingQueue<T>() private var isProcessing = false
private val queue = mutableListOf<T>()
fun invalidateList( fun invalidateList(
newObject: T, newObject: T,
onUpdate: suspend (Set<T>) -> Unit, onUpdate: suspend (Set<T>) -> Unit,
) { ) {
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
// if it was true already, returns.
return
}
scope.launch(dispatcher) { scope.launch(dispatcher) {
try { mutex.withLock {
while (true) { queue.add(newObject)
val batch = mutableSetOf<T>()
queue.drainTo(batch) if (isProcessing) {
if (batch.isNotEmpty()) { return@launch
onUpdate(batch) }
} else { isProcessing = true
break }
processLoop@ while (true) {
val batch =
mutex.withLock {
if (queue.isEmpty()) {
isProcessing = false
null
} else {
val items = queue.toSet()
queue.clear()
items
}
} }
delay(delay) if (batch == null) break@processLoop
}
} finally { if (batch.isNotEmpty()) {
withContext(NonCancellable) { onUpdate(batch)
onlyOneInBlock.set(false)
} }
delay(delay)
} }
} }
} }
@@ -18,9 +18,9 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.commons.ui.feeds
import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.commons.utils.logTime
abstract class AdditiveFeedFilter<T> : abstract class AdditiveFeedFilter<T> :
FeedFilter<T>(), FeedFilter<T>(),
@@ -30,7 +30,7 @@ abstract class AdditiveFeedFilter<T> :
newItems: Set<T>, newItems: Set<T>,
): List<T> = ): List<T> =
logTime( logTime(
debugMessage = { "${this.javaClass.simpleName} AdditiveFeedFilter updating ${newItems.size} new items to ${it.size} items" }, debugMessage = { "${this::class.simpleName} AdditiveFeedFilter updating ${newItems.size} new items to ${it.size} items" },
) { ) {
val newItemsToBeAdded = applyFilter(newItems) val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) { if (newItemsToBeAdded.isNotEmpty()) {
@@ -18,19 +18,17 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.feeds package com.vitorpamplona.amethyst.commons.ui.feeds
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.service.BasicBundledInsert import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert
import com.vitorpamplona.amethyst.service.BasicBundledUpdate import com.vitorpamplona.amethyst.commons.service.BasicBundledUpdate
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.utils.equalImmutableLists
import com.vitorpamplona.amethyst.ui.dal.IFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.utils.flattenToSet import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -45,6 +43,7 @@ import kotlinx.coroutines.launch
class FeedContentState( class FeedContentState(
val localFilter: IFeedFilter<Note>, val localFilter: IFeedFilter<Note>,
val viewModelScope: CoroutineScope, val viewModelScope: CoroutineScope,
val cacheProvider: ICacheProvider,
) : InvalidatableContent { ) : InvalidatableContent {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading) private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow() val feedContent = _feedContent.asStateFlow()
@@ -152,7 +151,7 @@ class FeedContentState(
.filter { .filter {
val noteEvent = it.event val noteEvent = it.event
if (noteEvent != null) { if (noteEvent != null) {
!LocalCache.deletionIndex.hasBeenDeleted(noteEvent) !cacheProvider.hasBeenDeleted(noteEvent)
} else { } else {
false false
} }
@@ -18,15 +18,15 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.commons.ui.feeds
import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.commons.utils.logTime
abstract class FeedFilter<T> : IFeedFilter<T> { abstract class FeedFilter<T> : IFeedFilter<T> {
override fun loadTop(): List<T> { override fun loadTop(): List<T> {
val feed = val feed =
logTime( logTime(
debugMessage = { "${this.javaClass.simpleName} FeedFilter returning ${it.size} objects" }, debugMessage = { "${this::class.simpleName} FeedFilter returning ${it.size} objects" },
block = ::feed, block = ::feed,
) )
return feed.take(limit()) return feed.take(limit())
@@ -18,11 +18,11 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.feeds package com.vitorpamplona.amethyst.commons.ui.feeds
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.commons.ui.feeds
interface IAdditiveFeedFilter<T> : IFeedFilter<T> { interface IAdditiveFeedFilter<T> : IFeedFilter<T> {
fun applyFilter(newItems: Set<T>): Set<T> fun applyFilter(newItems: Set<T>): Set<T>
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.commons.ui.feeds
interface IFeedFilter<T> { interface IFeedFilter<T> {
fun loadTop(): List<T> fun loadTop(): List<T>
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.feeds package com.vitorpamplona.amethyst.commons.ui.feeds
import androidx.compose.runtime.State import androidx.compose.runtime.State
@@ -0,0 +1,94 @@
/**
* 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.ui.thread
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* Creates a zebra pattern where each bar represents a reply level in a thread.
* Used to visually indicate nesting depth in thread conversations.
*
* @param level The current nesting level (0 = root, 1 = first reply, etc.)
* @param color The color used for non-selected level bars
* @param selected The color used for the current/selected level bar
*/
fun Modifier.drawReplyLevel(
level: State<Int>,
color: Color,
selected: Color,
): Modifier =
this
.drawBehind {
val paddingDp = 2
val strokeWidthDp = 2
val levelWidthDp = strokeWidthDp + 1
val padding = paddingDp.dp.toPx()
val strokeWidth = strokeWidthDp.dp.toPx()
val levelWidth = levelWidthDp.dp.toPx()
repeat(level.value) {
this.drawLine(
if (it == level.value - 1) selected else color,
Offset(padding + it * levelWidth, 0f),
Offset(padding + it * levelWidth, size.height),
strokeWidth = strokeWidth,
)
}
return@drawBehind
}.padding(start = (2 + (level.value * 3)).dp)
/**
* Overload for non-State level value.
*/
fun Modifier.drawReplyLevel(
level: Int,
color: Color,
selected: Color,
): Modifier =
this
.drawBehind {
val paddingDp = 2
val strokeWidthDp = 2
val levelWidthDp = strokeWidthDp + 1
val padding = paddingDp.dp.toPx()
val strokeWidth = strokeWidthDp.dp.toPx()
val levelWidth = levelWidthDp.dp.toPx()
repeat(level) {
this.drawLine(
if (it == level - 1) selected else color,
Offset(padding + it * levelWidth, 0f),
Offset(padding + it * levelWidth, size.height),
strokeWidth = strokeWidth,
)
}
return@drawBehind
}.padding(start = (2 + (level * 3)).dp)
@@ -0,0 +1,62 @@
/**
* 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.utils
import com.vitorpamplona.quartz.utils.Log
import kotlin.time.DurationUnit
import kotlin.time.measureTimedValue
/**
* Platform-specific debug flag.
* Android: checks BuildConfig.DEBUG
* Desktop: can be configured via system property
*/
expect val isDebug: Boolean
inline fun <T> logTime(
debugMessage: String,
minToReportMs: Int = 1,
block: () -> T,
): T =
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
if (elapsed.inWholeMilliseconds > minToReportMs) {
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage")
}
result
} else {
block()
}
inline fun <T> logTime(
debugMessage: (T) -> String,
minToReportMs: Int = 1,
block: () -> T,
): T =
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
if (elapsed.inWholeMilliseconds > minToReportMs) {
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}")
}
result
} else {
block()
}
@@ -0,0 +1,37 @@
/**
* 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.utils
import kotlinx.collections.immutable.ImmutableList
fun <T> equalImmutableLists(
list1: ImmutableList<T>,
list2: ImmutableList<T>,
): Boolean {
if (list1 === list2) return true
if (list1.size != list2.size) return false
for (i in 0 until list1.size) {
if (list1[i] !== list2[i]) {
return false
}
}
return true
}
@@ -0,0 +1,72 @@
/**
* 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.viewmodels
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
abstract class FeedViewModel(
localFilter: FeedFilter<Note>,
val cacheProvider: ICacheProvider,
) : ViewModel(),
InvalidatableContent {
val feedState = FeedContentState(localFilter, viewModelScope, cacheProvider)
override val isRefreshing = feedState.isRefreshing
fun sendToTop() = feedState.sendToTop()
suspend fun sentToTop() = feedState.sentToTop()
override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing)
init {
Log.d("Init", "Starting new Model: ${this::class.simpleName}")
viewModelScope.launch(Dispatchers.IO) {
cacheProvider.getEventStream().newEventBundles.collect { newNotes ->
Log.d("Rendering Metrics", "Update feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}")
feedState.updateFeedWith(newNotes)
}
}
viewModelScope.launch(Dispatchers.IO) {
cacheProvider.getEventStream().deletedEventBundles.collect { newNotes ->
Log.d("Rendering Metrics", "Delete from feeds: ${this@FeedViewModel::class.simpleName} with ${newNotes.size}")
feedState.deleteFromFeed(newNotes)
}
}
}
override fun onCleared() {
Log.d("Init", "OnCleared: ${this::class.simpleName}")
super.onCleared()
}
}
@@ -0,0 +1,97 @@
/**
* 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.viewmodels.thread
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.ThreadLevelCalculator
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
abstract class LevelFeedViewModel(
localFilter: FeedFilter<Note>,
cacheProvider: ICacheProvider,
) : FeedViewModel(localFilter, cacheProvider) {
var llState: LazyListState by mutableStateOf(LazyListState(0, 0))
val hasDragged = mutableStateOf(false)
val selectedIDHex =
llState.interactionSource.interactions
.onEach {
if (it is DragInteraction.Start) {
hasDragged.value = true
}
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null,
)
@OptIn(ExperimentalCoroutinesApi::class)
val levelCacheFlow: StateFlow<Map<Note, Int>> =
feedState.feedContent
.transformLatest { feed ->
emitAll(
if (feed is FeedState.Loaded) {
feed.feed.map {
val cache = mutableMapOf<Note, Int>()
it.list.forEach {
ThreadLevelCalculator.replyLevel(it, cache)
}
cache
}
} else {
MutableStateFlow(mapOf())
},
)
}.flowOn(Dispatchers.IO)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
mapOf(),
)
fun levelFlowForItem(note: Note) =
levelCacheFlow
.map {
it[note] ?: 0
}.distinctUntilChanged()
}
@@ -18,34 +18,46 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal package com.vitorpamplona.amethyst.commons.viewmodels.thread
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.model.LevelSignature import com.vitorpamplona.amethyst.commons.model.LevelSignature
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.ThreadLevelCalculator import com.vitorpamplona.amethyst.commons.model.ThreadLevelCalculator
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
/**
* Filter for assembling and sorting thread feeds.
*
* This filter uses ThreadAssembler to find all notes in a thread and
* ThreadLevelCalculator to sort them by reply level and relevance.
*
* @param account The current user's account (provides user profile and following set)
* @param noteId The root note ID of the thread to display
* @param cacheProvider The cache provider for accessing notes
*/
@Immutable @Immutable
class ThreadFeedFilter( class ThreadFeedFilter(
val account: Account, val account: IAccount,
private val noteId: String, private val noteId: String,
private val cacheProvider: ICacheProvider,
) : FeedFilter<Note>() { ) : FeedFilter<Note>() {
override fun feedKey(): String = noteId override fun feedKey(): String = noteId
override fun feed(): List<Note> { override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf() val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
val followingKeySet = account.kind3FollowList.flow.value.authors val followingKeySet = account.followingKeySet()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList() val eventsToWatch = ThreadAssembler(cacheProvider).findThreadFor(noteId) ?: return emptyList()
// Filter out drafts made by other accounts on device // Filter out drafts made by other accounts on device
val filteredEvents = val filteredEvents =
eventsToWatch.allNotes eventsToWatch.allNotes
.filter { !it.isDraft() || (it.author?.pubkeyHex == account.userProfile().pubkeyHex) } .filter { !it.isDraft() || (it.author?.pubkeyHex == account.pubKey) }
.toImmutableSet() .toImmutableSet()
val filteredThreadInfo = ThreadAssembler.ThreadInfo(eventsToWatch.root, filteredEvents) val filteredThreadInfo = ThreadAssembler.ThreadInfo(eventsToWatch.root, filteredEvents)
@@ -86,3 +86,48 @@ fun createContactListSubscription(
onEvent = onEvent, onEvent = onEvent,
onEose = onEose, onEose = onEose,
) )
/**
* Creates a subscription config for fetching a specific note by ID.
*/
fun createNoteSubscription(
relays: Set<NormalizedRelayUrl>,
noteId: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("note-${noteId.take(8)}"),
filters = listOf(FilterBuilders.byIds(listOf(noteId))),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for fetching all replies to a note (thread).
*
* @param noteId The root note ID to fetch replies for
* @param limit Maximum number of reply events to request
*/
fun createThreadRepliesSubscription(
relays: Set<NormalizedRelayUrl>,
noteId: String,
limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("thread-${noteId.take(8)}"),
filters =
listOf(
FilterBuilders.byETags(
eventIds = listOf(noteId),
kinds = listOf(1), // TextNoteEvent
limit = limit,
),
),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
@@ -0,0 +1,33 @@
/**
* 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.model
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
actual fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
@@ -0,0 +1,23 @@
/**
* 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.utils
actual val isDebug: Boolean = System.getProperty("amethyst.debug", "false").toBoolean()
@@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -110,6 +111,10 @@ sealed class DesktopScreen {
val pubKeyHex: String, val pubKeyHex: String,
) : DesktopScreen() ) : DesktopScreen()
data class Thread(
val noteId: String,
) : DesktopScreen()
object Settings : DesktopScreen() object Settings : DesktopScreen()
} }
@@ -351,6 +356,9 @@ fun MainContent(
onNavigateToProfile = { pubKeyHex -> onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
}, },
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
) )
DesktopScreen.Search -> SearchPlaceholder() DesktopScreen.Search -> SearchPlaceholder()
DesktopScreen.Messages -> MessagesPlaceholder() DesktopScreen.Messages -> MessagesPlaceholder()
@@ -377,6 +385,19 @@ fun MainContent(
onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
}, },
) )
is DesktopScreen.Thread ->
ThreadScreen(
noteId = currentScreen.noteId,
relayManager = relayManager,
account = account,
onBack = { onScreenChange(DesktopScreen.Feed) },
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account) DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account)
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -75,8 +76,14 @@ fun FeedNoteCard(
account: AccountState.LoggedIn?, account: AccountState.LoggedIn?,
onReply: () -> Unit, onReply: () -> Unit,
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
) { ) {
Column { Column(
modifier =
Modifier.clickable {
onNavigateToThread(event.id)
},
) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(), note = event.toNoteDisplayData(),
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
@@ -101,6 +108,7 @@ fun FeedScreen(
account: AccountState.LoggedIn? = null, account: AccountState.LoggedIn? = null,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -274,6 +282,7 @@ fun FeedScreen(
account = account, account = account,
onReply = { replyToEvent = event }, onReply = { replyToEvent = event },
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
) )
} }
} }
@@ -0,0 +1,277 @@
/**
* 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.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* Desktop Thread Screen - displays a note and all its replies in a thread view.
*
* Uses the shared drawReplyLevel modifier from commons to display reply nesting.
*/
@Composable
fun ThreadScreen(
noteId: String,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn?,
onBack: () -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
// State for the root note
var rootNote by remember { mutableStateOf<Event?>(null) }
// State for reply events
val replyEventState =
remember(noteId) {
EventCollectionState<Event>(
getId = { it.id },
sortComparator = compareBy { it.createdAt },
maxSize = 500,
scope = scope,
)
}
val replyEvents by replyEventState.items.collectAsState()
// Cache for calculating reply levels
val levelCache = remember(noteId) { mutableMapOf<String, Int>() }
// Subscribe to the root note
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
createNoteSubscription(
relays = configuredRelays,
noteId = noteId,
onEvent = { event, _, _, _ ->
if (event.id == noteId) {
rootNote = event
levelCache[event.id] = 0
}
},
)
} else {
null
}
}
// Subscribe to replies
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
createThreadRepliesSubscription(
relays = configuredRelays,
noteId = noteId,
onEvent = { event, _, _, _ ->
replyEventState.addItem(event)
},
)
} else {
null
}
}
// Calculate reply level for an event based on e-tags
fun calculateLevel(event: Event): Int {
levelCache[event.id]?.let { return it }
// Find the event this is replying to (last e-tag or marked reply/root)
val replyToId = findReplyToId(event)
val level =
if (replyToId == null || replyToId == noteId) {
1 // Direct reply to root
} else {
(levelCache[replyToId] ?: 0) + 1
}
levelCache[event.id] = level
return level
}
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(24.dp),
)
}
Spacer(Modifier.width(8.dp))
Text(
"Thread",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (rootNote == null) {
LoadingState("Loading thread...")
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Root note (no reply level indicator)
item(key = noteId) {
Column(
modifier =
Modifier.clickable {
// Already viewing this thread, no-op
},
) {
NoteCard(
note = rootNote!!.toNoteDisplayData(),
onAuthorClick = onNavigateToProfile,
)
if (account != null) {
NoteActionsRow(
event = rootNote!!,
relayManager = relayManager,
account = account,
onReplyClick = { /* TODO: Open reply dialog */ },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
HorizontalDivider(thickness = 1.dp)
}
// Reply notes with level indicators
items(replyEvents, key = { it.id }) { event ->
val level = calculateLevel(event)
Column(
modifier =
Modifier
.drawReplyLevel(
level = level,
color = MaterialTheme.colorScheme.outlineVariant,
selected =
if (event.id == noteId) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
},
).clickable {
onNavigateToThread(event.id)
},
) {
NoteCard(
note = event.toNoteDisplayData(),
onAuthorClick = onNavigateToProfile,
)
if (account != null) {
NoteActionsRow(
event = event,
relayManager = relayManager,
account = account,
onReplyClick = { /* TODO: Open reply dialog */ },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
HorizontalDivider(thickness = 1.dp)
}
// Empty state for no replies
if (replyEvents.isEmpty()) {
item {
Spacer(Modifier.height(32.dp))
Text(
"No replies yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
}
}
/**
* Finds the event ID this event is replying to.
* Uses NIP-10 markers (reply/root) or falls back to last e-tag.
*/
private fun findReplyToId(event: Event): String? {
val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" }
if (eTags.isEmpty()) return null
// Check for NIP-10 marked tags first
val replyTag = eTags.find { it.size >= 4 && it[3] == "reply" }
if (replyTag != null) return replyTag[1]
val rootTag = eTags.find { it.size >= 4 && it[3] == "root" }
if (rootTag != null && eTags.size == 1) return rootTag[1]
// Fall back to positional (last e-tag is the reply-to)
return eTags.lastOrNull()?.get(1)
}