diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt new file mode 100644 index 000000000..da4c6e981 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListRepository.kt @@ -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.nip02FollowList + +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +/** + * Narrow repository interface for Kind3FollowListState's settings needs. + * Follows the established pattern of EphemeralChatRepository / PublicChatListRepository. + */ +interface Kind3FollowListRepository { + val backupContactList: ContactListEvent? + + fun updateContactListTo(event: ContactListEvent) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt new file mode 100644 index 000000000..c7e3b6066 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt @@ -0,0 +1,181 @@ +/* + * 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.nip02FollowList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class Kind3FollowListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: Kind3FollowListRepository, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val user = cache.getOrCreateUser(signer.pubKey) + + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val note = cache.getOrCreateAddressableNote(getFollowListAddress()) + + fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey) + + fun getFollowListFlow(): StateFlow = note.flow().metadata.stateFlow + + fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent + + @OptIn(ExperimentalCoroutinesApi::class) + private val innerFlow: Flow = + getFollowListFlow().transformLatest { + emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList)) + } + + val flow = + innerFlow + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + // this has priority. + buildKind3Follows(getFollowListEvent() ?: settings.backupContactList), + ) + + // Creates a long-term reference for all follows of a user + val userList = + flow + .map { kind3Follows -> + kind3Follows.authors.mapNotNull { + runCatching { cache.getOrCreateUser(it) }.getOrNull() + } + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + // this has priority. + flow.value.authors.mapNotNull { + runCatching { cache.getOrCreateUser(it) }.getOrNull() + }, + ) + + /** + This contains a big OR of everything the user wants to see in the a single feed. + */ + @Immutable + class Kind3Follows( + val authors: Set = emptySet(), + val authorsPlusMe: Set, + ) + + fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows { + // makes sure the output include only valid p tags + val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() + + return Kind3Follows( + authors = verifiedFollowingUsers, + authorsPlusMe = verifiedFollowingUsers + signer.pubKey, + ) + } + + suspend fun follow(users: List): ContactListEvent { + val contactList = getFollowListEvent() + + val contacts = + users.map { + ContactTag(it.pubkeyHex, it.bestRelayHint(), null) + } + + return if (contactList != null) { + ContactListEvent.followUsers(contactList, contacts, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = contacts, + relayUse = emptyMap(), + signer = signer, + ) + } + } + + suspend fun follow(user: User): ContactListEvent { + val contactList = getFollowListEvent() + + return if (contactList != null) { + ContactListEvent.followUser(contactList, user.pubkeyHex, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), + relayUse = emptyMap(), + signer = signer, + ) + } + } + + suspend fun unfollow(user: User): ContactListEvent? { + val contactList = getFollowListEvent() + + return if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser( + contactList, + user.pubkeyHex, + signer, + ) + } else { + null + } + } + + init { + settings.backupContactList?.let { + Log.d("AccountRegisterObservers", "Loading saved ${it.tags.size} contacts") + + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Kind 3 Collector Start") + getFollowListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}") + (it.note.event as? ContactListEvent)?.let { + settings.updateContactListTo(it) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt new file mode 100644 index 000000000..6b5889074 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListRepository.kt @@ -0,0 +1,40 @@ +/* + * 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.nip65RelayList + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Narrow repository interface for Nip65RelayListState's settings needs. + * Follows the established pattern of EphemeralChatRepository / PublicChatListRepository. + */ +interface Nip65RelayListRepository { + val backupNIP65RelayList: AdvertisedRelayListEvent? + + fun updateNIP65RelayList(event: AdvertisedRelayListEvent) + + /** Default relay set when no NIP-65 list is available (write relays). */ + val defaultOutboxRelays: Set + + /** Default relay set when no NIP-65 list is available (read relays). */ + val defaultInboxRelays: Set +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt new file mode 100644 index 000000000..b93f17d53 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt @@ -0,0 +1,160 @@ +/* + * 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.nip65RelayList + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class Nip65RelayListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: Nip65RelayListRepository, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val nip65ListNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress()) + + fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey) + + fun getNIP65RelayListFlow(): StateFlow = nip65ListNote.flow().metadata.stateFlow + + fun getNIP65RelayList(): AdvertisedRelayListEvent? = nip65ListNote.event as? AdvertisedRelayListEvent + + fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: settings.defaultOutboxRelays + + fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set = nip65Event(note)?.readRelaysNorm()?.toSet() ?: settings.defaultInboxRelays + + fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet() + + fun normalizeNIP65ReadRelayListNoDefaults(note: Note): Set = nip65Event(note)?.readRelaysNorm()?.toSet() ?: emptySet() + + fun normalizeNIP65AllRelayListWithBackup(note: Note): Set = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: settings.defaultOutboxRelays + + fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet() + + val outboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65WriteRelayListWithBackup(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val outboxFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListNoDefaults(it.note) } + .onStart { emit(normalizeNIP65WriteRelayListNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListNoDefaults(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val allFlowNoDefaults = + getNIP65RelayListFlow() + .map { normalizeNIP65AllRelayListWithBackupNoDefaults(it.note) } + .onStart { emit(normalizeNIP65AllRelayListWithBackupNoDefaults(nip65ListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(relays: List): AdvertisedRelayListEvent { + val nip65RelayList = getNIP65RelayList() + + return if (nip65RelayList != null) { + AdvertisedRelayListEvent.replaceRelayListWith( + earlierVersion = nip65RelayList, + newRelays = relays, + signer = signer, + ) + } else { + AdvertisedRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + ) + } + } + + init { + settings.backupNIP65RelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") + getNIP65RelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}") + (it.note.event as? AdvertisedRelayListEvent)?.let { + settings.updateNIP65RelayList(it) + } + } + } + } +}