diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b424939e3..51f7a456f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -89,6 +89,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent +import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.events.ReportEvent @@ -233,12 +234,14 @@ class Account( getNIP65RelayListFlow(), getDMRelayListFlow(), getSearchRelayListFlow(), + getPrivateOutboxRelayListFlow(), userProfile().flow().relays.stateFlow, - ) { nip65RelayList, dmRelayList, searchRelayList, userProfile -> + ) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile -> val baseRelaySet = activeRelays() ?: convertLocalRelays() val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet() val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.toSet() ?: Constants.defaultSearchRelaySet val nip65RelaySet = (nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays() + val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays() ?: emptySet() var mappedRelaySet = baseRelaySet.map { @@ -270,6 +273,21 @@ class Account( } } + mappedRelaySet = + mappedRelaySet.map { + if (privateOutboxRelaySet.contains(it.url) == true) { + Relay(it.url, true, true, it.activeTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) + } else { + it + } + } + + privateOutboxRelaySet.forEach { newUrl -> + if (mappedRelaySet.filter { it.url == newUrl }.isEmpty()) { + mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) + } + } + mappedRelaySet = mappedRelaySet.map { if (localRelayServers.contains(it.url) == true) { @@ -1416,7 +1434,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> - Client.send(draftEvent, relayList = relayList) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent, relayList = relayList) + } LocalCache.justConsume(draftEvent, null) } } @@ -1480,7 +1503,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it, signer) { draftEvent -> - Client.send(draftEvent, relayList = relayList) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent, relayList = relayList) + } LocalCache.justConsume(draftEvent, null) } } @@ -1506,15 +1534,15 @@ class Account( fun deleteDraft(draftTag: String) { val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) - LocalCache.getAddressableNoteIfExists(key)?.let { - val noteEvent = it.event + LocalCache.getAddressableNoteIfExists(key)?.let { note -> + val noteEvent = note.event if (noteEvent is DraftEvent) { noteEvent.createDeletedEvent(signer) { - Client.send(it) + Client.sendPrivately(it, relayList = note.relays.map { it.url }) LocalCache.justConsume(it, null) } } - delete(it) + delete(note) } } @@ -1564,7 +1592,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it, signer) { draftEvent -> - Client.send(draftEvent, relayList = relayList) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent, relayList = relayList) + } LocalCache.justConsume(draftEvent, null) } } @@ -1657,7 +1690,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it, signer) { draftEvent -> - Client.send(draftEvent, relayList = relayList) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent, relayList = relayList) + } LocalCache.justConsume(draftEvent, null) } } @@ -1711,7 +1749,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it, signer) { draftEvent -> - Client.send(draftEvent) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent) + } LocalCache.justConsume(draftEvent, null) } } @@ -1758,7 +1801,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it, signer) { draftEvent -> - Client.send(draftEvent) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent) + } LocalCache.justConsume(draftEvent, null) } } @@ -1832,7 +1880,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> - Client.send(draftEvent) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent) + } LocalCache.justConsume(draftEvent, null) } } @@ -1880,7 +1933,12 @@ class Account( deleteDraft(draftTag) } else { DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent -> - Client.send(draftEvent) + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent) + } LocalCache.justConsume(draftEvent, null) } } @@ -2655,11 +2713,7 @@ class Account( fun saveDMRelayList(dmRelays: List) { if (!isWriteable()) return - val relayListForDMs = - LocalCache.getOrCreateAddressableNote( - ChatMessageRelayListEvent.createAddressATag(signer.pubKey), - ).event as? ChatMessageRelayListEvent - + val relayListForDMs = getDMRelayList() if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { ChatMessageRelayListEvent.updateRelayList( earlierVersion = relayListForDMs, @@ -2680,6 +2734,45 @@ class Account( } } + fun getPrivateOutboxRelayListNote(): AddressableNote { + return LocalCache.getOrCreateAddressableNote( + PrivateOutboxRelayListEvent.createAddressATag(signer.pubKey), + ) + } + + fun getPrivateOutboxRelayListFlow(): StateFlow { + return getPrivateOutboxRelayListNote().flow().metadata.stateFlow + } + + fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? { + return getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent + } + + fun savePrivateOutboxRelayList(relays: List) { + if (!isWriteable()) return + + val relayListForPrivateOutbox = getPrivateOutboxRelayList() + + if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) { + PrivateOutboxRelayListEvent.updateRelayList( + earlierVersion = relayListForPrivateOutbox, + relays = relays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + PrivateOutboxRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + fun getSearchRelayListNote(): AddressableNote { return LocalCache.getOrCreateAddressableNote( SearchRelayListEvent.createAddressATag(signer.pubKey), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 0e8145c6d..6e09fbfe2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -102,6 +102,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent +import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RecommendRelayEvent import com.vitorpamplona.quartz.events.RelaySetEvent @@ -929,6 +930,13 @@ object LocalCache { consumeBaseReplaceable(event, relay) } + private fun consume( + event: PrivateOutboxRelayListEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + private fun consume( event: SearchRelayListEvent, relay: Relay?, @@ -2565,6 +2573,7 @@ object LocalCache { is NNSEvent -> comsume(event, relay) is OtsEvent -> consume(event, relay) is PrivateDmEvent -> consume(event, relay) + is PrivateOutboxRelayListEvent -> consume(event, relay) is PinListEvent -> consume(event, relay) is PeopleListEvent -> consume(event, relay) is PollNoteEvent -> consume(event, relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index d7dfc7268..62437f27a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PollNoteEvent +import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent @@ -104,7 +105,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND), + kinds = listOf(StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, PrivateOutboxRelayListEvent.KIND), authors = listOf(account.userProfile().pubkeyHex), limit = 10, ), @@ -123,6 +124,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { ContactListEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, + PrivateOutboxRelayListEvent.KIND, SearchRelayListEvent.KIND, FileServersEvent.KIND, MuteListEvent.KIND, @@ -285,6 +287,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { if (LocalCache.justVerify(event)) { when (event) { + is PrivateOutboxRelayListEvent -> { + val note = LocalCache.getAddressableNoteIfExists(event.addressTag()) + val noteEvent = note?.event + if (noteEvent == null || event.createdAt > noteEvent.createdAt()) { + event.privateTags(account.signer) { + LocalCache.justConsume(event, relay) + } + } + } + is DraftEvent -> { // Avoid decrypting over and over again if the event already exist. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt index 6883ec273..8da4de250 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt @@ -74,6 +74,9 @@ fun AllRelayListView( val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle() val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle() + val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel() + val privateOutboxFeedState by privateOutboxViewModel.relays.collectAsStateWithLifecycle() + val searchViewModel: SearchRelayListViewModel = viewModel() val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle() @@ -86,6 +89,7 @@ fun AllRelayListView( nip65ViewModel.load(accountViewModel.account) searchViewModel.load(accountViewModel.account) localViewModel.load(accountViewModel.account) + privateOutboxViewModel.load(accountViewModel.account) } Dialog( @@ -115,6 +119,7 @@ fun AllRelayListView( nip65ViewModel.create() searchViewModel.create() localViewModel.create() + privateOutboxViewModel.create() onClose() }, true, @@ -127,6 +132,11 @@ fun AllRelayListView( CloseButton( onPress = { kind3ViewModel.clear() + dmViewModel.clear() + nip65ViewModel.clear() + searchViewModel.clear() + localViewModel.clear() + privateOutboxViewModel.clear() onClose() }, ) @@ -177,6 +187,14 @@ fun AllRelayListView( } renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav) + item { + SettingsCategory( + stringResource(R.string.private_outbox_section), + stringResource(R.string.private_outbox_section_explainer), + ) + } + renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, onClose, nav) + item { SettingsCategoryWithButton( stringResource(R.string.search_section), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt index 1632360d2..af9d2bc7d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt @@ -59,7 +59,7 @@ fun LazyListScope.renderLocalItems( onClose: () -> Unit, nav: (String) -> Unit, ) { - itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/PrivateOutboxRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/PrivateOutboxRelayListView.kt new file mode 100644 index 000000000..009da474d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/PrivateOutboxRelayListView.kt @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2024 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.actions.relays + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun PrivateOutboxRelayList( + postViewModel: PrivateOutboxRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: (String) -> Unit, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderPrivateOutboxItems(feedState, postViewModel, accountViewModel, onClose, nav) + } + } +} + +fun LazyListScope.renderPrivateOutboxItems( + feedState: List, + postViewModel: PrivateOutboxRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: (String) -> Unit, +) { + itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + ) { + onClose() + nav(it) + } + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(it) } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/PrivateOutboxRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/PrivateOutboxRelayListViewModel.kt new file mode 100644 index 000000000..c84f5b88f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/PrivateOutboxRelayListViewModel.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2024 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.actions.relays + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.Nip11CachedRetriever +import com.vitorpamplona.amethyst.service.relays.RelayPool +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PrivateOutboxRelayListViewModel : ViewModel() { + private lateinit var account: Account + + private val _relays = MutableStateFlow>(emptyList()) + val relays = _relays.asStateFlow() + + fun load(account: Account) { + this.account = account + clear() + loadRelayDocuments() + } + + fun create() { + viewModelScope.launch(Dispatchers.IO) { + account.savePrivateOutboxRelayList(_relays.value.map { it.url }) + clear() + } + } + + fun loadRelayDocuments() { + viewModelScope.launch(Dispatchers.IO) { + _relays.value.forEach { item -> + Nip11CachedRetriever.loadRelayInfo( + dirtyUrl = item.url, + onInfo = { + togglePaidRelay(item, it.limitation?.payment_required ?: false) + }, + onError = { url, errorCode, exceptionMessage -> }, + ) + } + } + } + + fun clear() { + _relays.update { + val relayList = account.getPrivateOutboxRelayList()?.relays() ?: emptyList() + + relayList.map { relayUrl -> + val liveRelay = RelayPool.getRelay(relayUrl) + val errorCounter = liveRelay?.errorCounter ?: 0 + val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 + val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 + val spamCounter = liveRelay?.spamCounter ?: 0 + + BasicRelaySetupInfo( + relayUrl, + errorCounter, + eventDownloadCounter, + eventUploadCounter, + spamCounter, + ) + }.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed() + } + } + + fun addRelay(relay: BasicRelaySetupInfo) { + if (relays.value.any { it.url == relay.url }) return + + _relays.update { it.plus(relay) } + } + + fun deleteRelay(relay: BasicRelaySetupInfo) { + _relays.update { it.minus(relay) } + } + + fun deleteAll() { + _relays.update { relays -> emptyList() } + } + + fun togglePaidRelay( + relay: BasicRelaySetupInfo, + paid: Boolean, + ) { + _relays.update { it.updated(relay, relay.copy(paidRelay = paid)) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 277d95db0..a0bace6c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -825,6 +825,8 @@ This relay type receives all replies, comments, likes and zaps to your posts. Insert between 1–3 relays and make sure they accept posts from anyone. DM Inbox Relays Insert between 1–3 relays to serve as your private inbox. Others will use these relays to send DMs to you. DM Inbox relays should accept any message from anyone, but only allow you to download them. Good options are:\n - inbox.nostr.wine (paid)\n - you.nostr1.com (personal relays - paid) + Private Relays + Insert between 1–3 relays to store events no one else can see, like your Drafts and/or app settings. Ideally, these relays are either local or require authentication before downloading each user\'s content. General Relays Amethyst uses these relays to download posts for you. Search Relays diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 9051a960e..d8e071057 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -124,6 +124,7 @@ class EventFactory { PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) + PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig) RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateOutboxRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateOutboxRelayListEvent.kt new file mode 100644 index 000000000..0121ebec6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateOutboxRelayListEvent.kt @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2024 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.quartz.events + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PrivateOutboxRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient private var privateTagsCache: Array>? = null + + override fun dTag() = FIXED_D_TAG + + fun relays(): List? { + return tags.mapNotNull { + if (it.size > 1 && it[0] == "relay") { + it[1] + } else { + null + } + }.plus( + privateTagsCache?.mapNotNull { + if (it.size > 1 && it[0] == "relay") { + it[1] + } else { + null + } + } ?: emptyList(), + ).ifEmpty { null } + } + + fun cachedPrivateTags(): Array>? { + return privateTagsCache + } + + fun privateTags( + signer: NostrSigner, + onReady: (Array>) -> Unit, + ) { + if (content.isEmpty()) { + onReady(emptyArray()) + return + } + + privateTagsCache?.let { + onReady(it) + return + } + + try { + signer.nip44Decrypt(content, pubKey) { + privateTagsCache = mapper.readValue>>(it) + privateTagsCache?.let { onReady(it) } + } + } catch (e: Throwable) { + Log.w("GeneralList", "Error parsing the JSON ${e.message}") + } + } + + companion object { + const val KIND = 10013 + const val FIXED_D_TAG = "" + val TAGS = arrayOf(arrayOf("alt", "Relay list to store private content from this author")) + + fun createAddressATag(pubKey: HexKey): ATag { + return ATag(KIND, pubKey, FIXED_D_TAG, null) + } + + fun createAddressTag(pubKey: HexKey): String { + return ATag.assembleATag(KIND, pubKey, FIXED_D_TAG) + } + + fun encryptTags( + privateTags: Array>? = null, + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + val msg = mapper.writeValueAsString(privateTags) + + signer.nip44Encrypt( + msg, + signer.pubKey, + onReady, + ) + } + + fun createTagArray(relays: List): Array> { + return relays.map { + arrayOf("relay", it) + }.toTypedArray() + } + + fun updateRelayList( + earlierVersion: PrivateOutboxRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PrivateOutboxRelayListEvent) -> Unit, + ) { + val tags = + earlierVersion.privateTagsCache?.filter { it[0] != "relay" }?.plus( + relays.map { + arrayOf("relay", it) + }, + )?.toTypedArray() ?: emptyArray() + + encryptTags(tags, signer) { + signer.sign(createdAt, KIND, TAGS, it) { + it.privateTagsCache = tags + onReady(it) + } + } + } + + fun createFromScratch( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PrivateOutboxRelayListEvent) -> Unit, + ) { + create(relays, signer, createdAt, onReady) + } + + fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PrivateOutboxRelayListEvent) -> Unit, + ) { + val privateTagArray = createTagArray(relays) + encryptTags(privateTagArray, signer) { privateTags -> + signer.sign(createdAt, KIND, TAGS, privateTags) { + it.privateTagsCache = privateTagArray + onReady(it) + } + } + } + } +}