Merge pull request #1663 from nrobi144/phase1-final
Desktop - Threads and Notifications + Empty state updates
This commit is contained in:
+2
-2
@@ -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
|
||||||
|
|||||||
+15
-14
@@ -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()
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+4
-3
@@ -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
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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")
|
||||||
|
|||||||
+2
-2
@@ -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")
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-16
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+31
@@ -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)
|
||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+2
-32
@@ -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(
|
||||||
|
|||||||
+10
-69
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-1
@@ -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,
|
||||||
|
|||||||
+5
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+33
@@ -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)
|
||||||
+25
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -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()
|
||||||
+8
-13
@@ -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
|
||||||
*/
|
*/
|
||||||
Vendored
+48
@@ -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>>
|
||||||
|
}
|
||||||
Vendored
+35
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+48
-35
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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()) {
|
||||||
+9
-10
@@ -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
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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())
|
||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
+1
-1
@@ -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>
|
||||||
+1
-1
@@ -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>
|
||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
+94
@@ -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
|
||||||
|
}
|
||||||
+72
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -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()
|
||||||
|
}
|
||||||
+23
-11
@@ -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)
|
||||||
|
|
||||||
+45
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
+33
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user