Add other functions and adapt them to the code already present.

This commit is contained in:
KotlinGeekDev
2025-08-06 18:56:15 +01:00
parent 7f8b2a6ccf
commit 981486f09a
12 changed files with 324 additions and 283 deletions

View File

@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.ots.OkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.service.ots.OkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -215,6 +216,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
@@ -839,6 +841,23 @@ class Account(
} }
} }
suspend fun getFollowSetNotes() =
withContext(Dispatchers.Default) {
val followSetNotes = LocalCache.getFollowSetNotesFor(userProfile())
userProfile().updateFollowSetNotes(followSetNotes)
// userProfile().followSets = followSetNotes
println("Number of follow sets: ${followSetNotes.size}")
}
suspend fun mapNoteToFollowSet(note: Note): FollowSet =
FollowSet
.mapEventToSet(
event = note.event as PeopleListEvent,
signer,
)
// fun followSetNotesFlow() = MutableStateFlow(userProfile().followSets)
suspend fun updateAttestations() = sendAutomatic(otsState.updateAttestations()) suspend fun updateAttestations() = sendAutomatic(otsState.updateAttestations())
suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user))
@@ -1609,60 +1628,6 @@ class Account(
suspend fun hideUser(pubkeyHex: HexKey) { suspend fun hideUser(pubkeyHex: HexKey) {
sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex))
fun getAppSpecificDataNote() = LocalCache.getOrCreateAddressableNote(AppSpecificDataEvent.createAddress(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG))
fun getAppSpecificDataFlow(): StateFlow<NoteState> = getAppSpecificDataNote().flow().metadata.stateFlow
fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(userProfile().pubkeyHex))
fun getMuteListNote() = LocalCache.getOrCreateAddressableNote(MuteListEvent.createAddress(userProfile().pubkeyHex))
suspend fun getFollowSetNotes() =
withContext(Dispatchers.Default) {
val followSetNotes = LocalCache.getFollowSetNotesFor(userProfile())
userProfile().updateFollowSetNotes(followSetNotes)
// userProfile().followSets = followSetNotes
println("Number of follow sets: ${followSetNotes.size}")
}
fun mapNoteToFollowSet(note: Note): FollowSet =
FollowSet
.mapEventToSet(
event = note.event as PeopleListEvent,
signer,
)
// fun followSetNotesFlow() = MutableStateFlow(userProfile().followSets)
fun getMuteListFlow(): StateFlow<NoteState> = getMuteListNote().flow().metadata.stateFlow
fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent
fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent
fun hideWord(word: String) {
val muteList = getMuteList()
if (muteList != null) {
MuteListEvent.addWord(
earlierVersion = muteList,
word = word,
isPrivate = true,
signer = signer,
) {
Amethyst.instance.client.send(it)
LocalCache.consume(it, null)
}
} else {
MuteListEvent.createListWithWord(
word = word,
isPrivate = true,
signer = signer,
) {
Amethyst.instance.client.send(it)
LocalCache.consume(it, null)
}
}
} }
suspend fun showUser(pubkeyHex: HexKey) { suspend fun showUser(pubkeyHex: HexKey) {

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in
@@ -32,9 +32,10 @@ class FollowSetFeedFilter(
override fun feedKey(): String = account.userProfile().pubkeyHex override fun feedKey(): String = account.userProfile().pubkeyHex
override fun feed(): List<FollowSet> { override fun feed(): List<FollowSet> {
val userFollowSets = account.userProfile().followSetNotes val followSetCache = mutableListOf<FollowSet>()
if (userFollowSets.isEmpty()) { account.scope.launch {
account.scope.launch { val userFollowSets = account.userProfile().followSetNotes
if (userFollowSets.isEmpty()) {
try { try {
account.getFollowSetNotes() account.getFollowSetNotes()
} catch (e: Exception) { } catch (e: Exception) {
@@ -43,8 +44,10 @@ class FollowSetFeedFilter(
null null
} }
} }
userFollowSets.map { account.mapNoteToFollowSet(it) }.forEach {
followSetCache.add(it)
}
} }
val followSets = userFollowSets.map { account.mapNoteToFollowSet(it) } return followSetCache.toList()
return followSets
} }
} }

View File

@@ -23,116 +23,19 @@ package com.vitorpamplona.amethyst.ui.screen
import android.util.Log import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
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.dal.FollowSetFeedFilter import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter
import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryResponseFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrListFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrListFeedViewModel
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class NostrChannelFeedViewModel(
val channel: Channel,
val account: Account,
) : FeedViewModel(ChannelFeedFilter(channel, account)) {
class Factory(
val channel: Channel,
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrChannelFeedViewModel : ViewModel> create(modelClass: Class<NostrChannelFeedViewModel>): NostrChannelFeedViewModel = NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel
}
}
class NostrChatroomFeedViewModel(
val user: ChatroomKey,
val account: Account,
) : FeedViewModel(ChatroomFeedFilter(user, account)) {
class Factory(
val user: ChatroomKey,
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrChatRoomFeedViewModel : ViewModel> create(modelClass: Class<NostrChatRoomFeedViewModel>): NostrChatRoomFeedViewModel = NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel
}
}
class NostrThreadFeedViewModel(
account: Account,
noteId: String,
) : LevelFeedViewModel(ThreadFeedFilter(account, noteId)) {
class Factory(
val account: Account,
val noteId: String,
) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel = NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel
}
}
class NostrHashtagFeedViewModel(
val hashtag: String,
val account: Account,
) : FeedViewModel(HashtagFeedFilter(hashtag, account)) {
class Factory(
val hashtag: String,
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrHashtagFeedViewModel : ViewModel> create(modelClass: Class<NostrHashtagFeedViewModel>): NostrHashtagFeedViewModel = NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel
}
}
class NostrGeoHashFeedViewModel(
val geohash: String,
val account: Account,
) : FeedViewModel(GeoHashFeedFilter(geohash, account)) {
class Factory(
val geohash: String,
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrGeoHashFeedViewModel : ViewModel> create(modelClass: Class<NostrGeoHashFeedViewModel>): NostrGeoHashFeedViewModel = NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel
}
}
class NostrCommunityFeedViewModel(
val note: AddressableNote,
val account: Account,
) : FeedViewModel(CommunityFeedFilter(note, account)) {
class Factory(
val note: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrCommunityFeedViewModel : ViewModel> create(modelClass: Class<NostrCommunityFeedViewModel>): NostrCommunityFeedViewModel = NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel
}
}
@Stable
class NostrBookmarkPublicFeedViewModel(
val account: Account,
) : FeedViewModel(BookmarkPublicFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrBookmarkPublicFeedViewModel : ViewModel> create(modelClass: Class<NostrBookmarkPublicFeedViewModel>): NostrBookmarkPublicFeedViewModel = NostrBookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel
}
}
@Stable
class NostrBookmarkPrivateFeedViewModel(
val account: Account,
) : FeedViewModel(BookmarkPrivateFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrBookmarkPrivateFeedViewModel : ViewModel> create(modelClass: Class<NostrBookmarkPrivateFeedViewModel>): NostrBookmarkPrivateFeedViewModel = NostrBookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel
}
}
@Stable @Stable
class NostrUserListFeedViewModel( class NostrUserListFeedViewModel(
val account: Account, val account: Account,
@@ -144,82 +47,6 @@ class NostrUserListFeedViewModel(
} }
} }
@Stable
class NostrNIP90ContentDiscoveryFeedViewModel(
val account: Account,
dvmkey: String,
requestid: String,
) : FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmkey, requestid)) {
class Factory(
val account: Account,
val dvmkey: String,
val requestid: String,
) : ViewModelProvider.Factory {
override fun <NostrNIP90ContentDiscoveryFeedViewModel : ViewModel> create(modelClass: Class<NostrNIP90ContentDiscoveryFeedViewModel>): NostrNIP90ContentDiscoveryFeedViewModel = NostrNIP90ContentDiscoveryFeedViewModel(account, dvmkey, requestid) as NostrNIP90ContentDiscoveryFeedViewModel
}
}
@Stable
class NostrDraftEventsFeedViewModel(
val account: Account,
) : FeedViewModel(DraftEventsFeedFilter(account)) {
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
override fun <NostrDraftEventsFeedViewModel : ViewModel> create(modelClass: Class<NostrDraftEventsFeedViewModel>): NostrDraftEventsFeedViewModel = NostrDraftEventsFeedViewModel(account) as NostrDraftEventsFeedViewModel
}
}
abstract class LevelFeedViewModel(
localFilter: FeedFilter<Note>,
) : FeedViewModel(localFilter) {
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.Default)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
mapOf(),
)
fun levelFlowForItem(note: Note) =
levelCacheFlow
.map {
it[note] ?: 0
}.distinctUntilChanged()
}
@Stable @Stable
abstract class FeedViewModel( abstract class FeedViewModel(
localFilter: FeedFilter<Note>, localFilter: FeedFilter<Note>,

View File

@@ -177,25 +177,9 @@ class AccountViewModel(
var firstRoute: Route? = null var firstRoute: Route? = null
// TODO: contact lists are not notes yet val toastManager = ToastManager()
// val kind3Relays: StateFlow<ContactListEvent?> = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey)
val normalizedKind3RelaySetFlow = val feedStates = AccountFeedContentStates(this)
account
.userProfile()
.flow()
.relays.stateFlow
.map { contactListState ->
checkNotInMainThread()
contactListState.user.latestContactList?.relays()?.map {
RelayUrlFormatter.normalize(it.key)
} ?: emptySet()
}.flowOn(Dispatchers.Default)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(10000, 10000),
emptySet(),
)
val followSetsFlow = val followSetsFlow =
account account
@@ -215,13 +199,6 @@ class AccountViewModel(
emptyList(), emptyList(),
) )
val dmRelays: StateFlow<ChatMessageRelayListEvent?> = observeByAuthor(ChatMessageRelayListEvent.KIND, account.signer.pubKey)
val searchRelays: StateFlow<SearchRelayListEvent?> = observeByAuthor(SearchRelayListEvent.KIND, account.signer.pubKey)
val toastManager = ToastManager()
val feedStates = AccountFeedContentStates(this)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val notificationHasNewItems = val notificationHasNewItems =
combineTransform( combineTransform(

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in
@@ -63,18 +63,18 @@ 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.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.navigation.INav import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.NostrUserListFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserListFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -118,7 +118,7 @@ fun ListsScreen(
}, },
openItem = { openItem = {
currentCoroutineScope.launch(Dispatchers.IO) { currentCoroutineScope.launch(Dispatchers.IO) {
val note = followSetsViewModel.getFollowSetAddressable(it, accountViewModel.account) val note = followSetsViewModel.getFollowSetNote(it, accountViewModel.account)
if (note != null) { if (note != null) {
val event = note.event as PeopleListEvent val event = note.event as PeopleListEvent
println("Found list, with title: ${event.nameOrTitle()}") println("Found list, with title: ${event.nameOrTitle()}")

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in
@@ -21,8 +21,9 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.value
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
@Stable @Stable
data class FollowSet( data class FollowSet(
@@ -33,7 +34,7 @@ data class FollowSet(
val profileList: Set<String>, val profileList: Set<String>,
) : NostrList(listVisibility = visibility, content = profileList) { ) : NostrList(listVisibility = visibility, content = profileList) {
companion object { companion object {
fun mapEventToSet( suspend fun mapEventToSet(
event: PeopleListEvent, event: PeopleListEvent,
signer: NostrSigner, signer: NostrSigner,
): FollowSet { ): FollowSet {
@@ -41,9 +42,12 @@ data class FollowSet(
val dTag = event.dTag() val dTag = event.dTag()
val listTitle = event.nameOrTitle() ?: dTag val listTitle = event.nameOrTitle() ?: dTag
val listDescription = event.description() ?: "" val listDescription = event.description() ?: ""
val publicFollows = event.filterTagList("p", null) val publicFollows = event.publicPeople().map { it.toTagArray() }.map { it.value() }
val privateFollows = mutableListOf<String>() val privateFollows =
event.privateTaggedUsers(signer) { userList -> privateFollows.addAll(userList) } event
.privatePeople(signer)
?.map { it.toTagArray() }
?.map { it.value() } ?: emptyList()
return if (publicFollows.isEmpty() && privateFollows.isNotEmpty()) { return if (publicFollows.isEmpty() && privateFollows.isNotEmpty()) {
FollowSet( FollowSet(
identifierTag = address.toValue(), identifierTag = address.toValue(),

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in
@@ -22,18 +22,22 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists
import android.util.Log import android.util.Log
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
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.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
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.dal.FollowSetFeedFilter
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent 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.ammolite.relays.BundledUpdate import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,9 +46,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
// TODO: Investigate the addition of feed filters, for bookmark sets and general ones. // TODO: Investigate the addition of feed filters, for bookmark sets and general ones.
abstract class NostrListFeedViewModel( open class NostrListFeedViewModel(
val dataSource: FeedFilter<FollowSet>, val dataSource: FeedFilter<FollowSet>,
) : ViewModel(), ) : ViewModel(),
InvalidatableContent { InvalidatableContent {
@@ -57,15 +62,28 @@ abstract class NostrListFeedViewModel(
} }
} }
fun getFollowSetAddressable( fun getFollowSetNote(
addressValue: String, noteIdentifier: String,
account: Account, account: Account,
): AddressableNote? { ): AddressableNote? {
checkNotInMainThread() checkNotInMainThread()
val potentialNote = LocalCache.getAddressableNoteIfExists(Address.parse(addressValue)!!) val potentialNote = account.userProfile().followSetNotes.find { it.dTag() == noteIdentifier }
return potentialNote return potentialNote
} }
fun followSetExistsWithName(
setName: String,
account: Account,
): Boolean {
checkNotInMainThread()
val potentialNote =
account
.userProfile()
.followSetNotes
.find { (it.event as PeopleListEvent).nameOrTitle() == setName }
return potentialNote != null
}
override val isRefreshing: MutableState<Boolean> = mutableStateOf(false) override val isRefreshing: MutableState<Boolean> = mutableStateOf(false)
private fun refreshSuspended() { private fun refreshSuspended() {
@@ -96,6 +114,111 @@ abstract class NostrListFeedViewModel(
} }
} }
fun addFollowSet(
setName: String,
setDescription: String?,
setType: ListVisibility,
account: Account,
) {
if (account.settings.isWriteable()) {
println("You are in read-only mode. Please login to make modifications.")
} else {
viewModelScope.launch(Dispatchers.IO) {
PeopleListEvent.createListWithDescription(
dTag = UUID.randomUUID().toString(),
title = setName,
description = setDescription,
isPrivate = setType == ListVisibility.Private,
signer = account.signer,
) {
account.sendMyPublicAndPrivateOutbox(it)
}
}
}
}
fun renameFollowSet(
newName: String,
followSet: FollowSet,
account: Account,
) {
if (!account.settings.isWriteable()) {
println("You are in read-only mode. Please login to make modifications.")
} else {
viewModelScope.launch(Dispatchers.IO) {
val setEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent
PeopleListEvent.modifyListName(
earlierVersion = setEvent,
newName = newName,
signer = account.signer,
) {
account.sendMyPublicAndPrivateOutbox(it)
}
}
}
}
fun deleteFollowSet(
followSet: FollowSet,
account: Account,
) {
if (!account.settings.isWriteable()) {
println("You are in read-only mode. Please login to make modifications.")
return
} else {
viewModelScope.launch(Dispatchers.IO) {
val followSetEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followSetEvent)))
account.sendMyPublicAndPrivateOutbox(deletionEvent)
}
}
}
fun addUserToSet(
userProfileHex: String,
followSet: FollowSet,
account: Account,
) {
if (!account.settings.isWriteable()) {
println("You are in read-only mode. Please login to make modifications.")
return
} else {
viewModelScope.launch(Dispatchers.IO) {
val followSetEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent
PeopleListEvent.addUser(
earlierVersion = followSetEvent,
pubKeyHex = userProfileHex,
isPrivate = followSet.visibility == ListVisibility.Private,
signer = account.signer,
) {
account.sendMyPublicAndPrivateOutbox(it)
}
}
}
}
fun removeUserFromSet(
userProfileHex: String,
followSet: FollowSet,
account: Account,
) {
if (!account.settings.isWriteable()) {
println("You are in read-only mode. Please login to make modifications.")
return
} else {
viewModelScope.launch(Dispatchers.IO) {
val followSetEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent
PeopleListEvent.removeUser(
earlierVersion = followSetEvent,
pubKeyHex = userProfileHex,
signer = account.signer,
) {
account.sendMyPublicAndPrivateOutbox(it)
}
}
}
}
private fun updateFeed(sets: ImmutableList<FollowSet>) { private fun updateFeed(sets: ImmutableList<FollowSet>) {
if (sets.isNotEmpty()) { if (sets.isNotEmpty()) {
_feedContent.update { FollowSetState.Loaded(sets) } _feedContent.update { FollowSetState.Loaded(sets) }
@@ -104,13 +227,14 @@ abstract class NostrListFeedViewModel(
} }
} }
private val bundler = BundledUpdate(1000, Dispatchers.IO) private val bundler = BundledUpdate(2000, Dispatchers.IO)
override fun invalidateData(ignoreIfDoing: Boolean) { override fun invalidateData(ignoreIfDoing: Boolean) {
// refresh() // refresh()
bundler.invalidate(ignoreIfDoing) { bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
refreshSuspended() refreshSuspended()
} }
} }
@@ -135,4 +259,11 @@ abstract class NostrListFeedViewModel(
collectorJob?.cancel() collectorJob?.cancel()
super.onCleared() super.onCleared()
} }
@Stable
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T = NostrListFeedViewModel(FollowSetFeedFilter(account)) as T
}
} }

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (c) 2024 Vitor Pamplona * Copyright (c) 2025 Vitor Pamplona
* *
* Permission is hereby granted, free of charge, to any person obtaining a copy of * 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 * this software and associated documentation files (the "Software"), to deal in

View File

@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.replaceAll
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -214,5 +215,138 @@ class PeopleListEvent(
initializer() initializer()
} }
suspend fun createListWithDescription(
dTag: String,
title: String,
description: String? = null,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
if (description == null) {
val newList =
create(
name = title,
person = UserTag(pubKey = signer.pubKey),
isPrivate = isPrivate,
signer = signer,
dTag = dTag,
createdAt = createdAt,
)
onReady(newList)
} else {
if (isPrivate) {
val event =
build(
name = title,
privatePeople = listOf(UserTag(pubKey = signer.pubKey)),
signer = signer,
dTag = dTag,
createdAt = createdAt,
) {
addUnique(arrayOf("description", description))
}
val list = signer.sign(event)
onReady(list)
} else {
val event =
build(
name = title,
publicPeople = listOf(UserTag(pubKey = signer.pubKey)),
signer = signer,
dTag = dTag,
createdAt = createdAt,
) {
addUnique(arrayOf("description", description))
}
val list = signer.sign(event)
onReady(list)
}
}
}
suspend fun createListWithUser(
name: String,
pubKeyHex: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
val newList =
create(
name = name,
person = UserTag(pubKey = pubKeyHex),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
)
onReady(newList)
}
suspend fun addUser(
earlierVersion: PeopleListEvent,
pubKeyHex: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
val newList =
add(
earlierVersion = earlierVersion,
person = UserTag(pubKey = pubKeyHex),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
)
onReady(newList)
}
suspend fun removeUser(
earlierVersion: PeopleListEvent,
pubKeyHex: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit,
) {
val updatedList =
remove(
earlierVersion = earlierVersion,
person = UserTag(pubKey = pubKeyHex),
signer = signer,
createdAt = createdAt,
)
onReady(updatedList)
}
suspend fun modifyListName(
earlierVersion: PeopleListEvent,
newName: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PeopleListEvent) -> Unit = {},
) {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val currentTitle = earlierVersion.tags.first { it[0] == NameTag.TAG_NAME || it[0] == TitleTag.TAG_NAME }
val newTitleTag =
if (currentTitle[0] == NameTag.TAG_NAME) {
NameTag.assemble(newName)
} else {
com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
.assemble(newName)
}
val modified =
resign(
publicTags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag),
privateTags = privateTags.replaceAll(currentTitle, newTitleTag),
signer = signer,
createdAt = createdAt,
)
onReady(modified)
}
} }
} }