Adds private outbox relays to save Draft events.

This commit is contained in:
Vitor Pamplona
2024-05-30 16:02:21 -04:00
parent 6ecb5ecc47
commit a38346c7d3
10 changed files with 510 additions and 20 deletions

View File

@@ -89,6 +89,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.ReportEvent
@@ -233,12 +234,14 @@ class Account(
getNIP65RelayListFlow(), getNIP65RelayListFlow(),
getDMRelayListFlow(), getDMRelayListFlow(),
getSearchRelayListFlow(), getSearchRelayListFlow(),
getPrivateOutboxRelayListFlow(),
userProfile().flow().relays.stateFlow, userProfile().flow().relays.stateFlow,
) { nip65RelayList, dmRelayList, searchRelayList, userProfile -> ) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile ->
val baseRelaySet = activeRelays() ?: convertLocalRelays() val baseRelaySet = activeRelays() ?: convertLocalRelays()
val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet() val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet()
val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.toSet() ?: Constants.defaultSearchRelaySet val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.toSet() ?: Constants.defaultSearchRelaySet
val nip65RelaySet = (nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays() val nip65RelaySet = (nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays()
val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays() ?: emptySet()
var mappedRelaySet = var mappedRelaySet =
baseRelaySet.map { 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 =
mappedRelaySet.map { mappedRelaySet.map {
if (localRelayServers.contains(it.url) == true) { if (localRelayServers.contains(it.url) == true) {
@@ -1416,7 +1434,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -1480,7 +1503,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it, signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -1506,15 +1534,15 @@ class Account(
fun deleteDraft(draftTag: String) { fun deleteDraft(draftTag: String) {
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
LocalCache.getAddressableNoteIfExists(key)?.let { LocalCache.getAddressableNoteIfExists(key)?.let { note ->
val noteEvent = it.event val noteEvent = note.event
if (noteEvent is DraftEvent) { if (noteEvent is DraftEvent) {
noteEvent.createDeletedEvent(signer) { noteEvent.createDeletedEvent(signer) {
Client.send(it) Client.sendPrivately(it, relayList = note.relays.map { it.url })
LocalCache.justConsume(it, null) LocalCache.justConsume(it, null)
} }
} }
delete(it) delete(note)
} }
} }
@@ -1564,7 +1592,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it, signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -1657,7 +1690,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it, signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -1711,7 +1749,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it, signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -1758,7 +1801,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it, signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -1832,7 +1880,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -1880,7 +1933,12 @@ class Account(
deleteDraft(draftTag) deleteDraft(draftTag)
} else { } else {
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent -> 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) LocalCache.justConsume(draftEvent, null)
} }
} }
@@ -2655,11 +2713,7 @@ class Account(
fun saveDMRelayList(dmRelays: List<String>) { fun saveDMRelayList(dmRelays: List<String>) {
if (!isWriteable()) return if (!isWriteable()) return
val relayListForDMs = val relayListForDMs = getDMRelayList()
LocalCache.getOrCreateAddressableNote(
ChatMessageRelayListEvent.createAddressATag(signer.pubKey),
).event as? ChatMessageRelayListEvent
if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) {
ChatMessageRelayListEvent.updateRelayList( ChatMessageRelayListEvent.updateRelayList(
earlierVersion = relayListForDMs, earlierVersion = relayListForDMs,
@@ -2680,6 +2734,45 @@ class Account(
} }
} }
fun getPrivateOutboxRelayListNote(): AddressableNote {
return LocalCache.getOrCreateAddressableNote(
PrivateOutboxRelayListEvent.createAddressATag(signer.pubKey),
)
}
fun getPrivateOutboxRelayListFlow(): StateFlow<NoteState> {
return getPrivateOutboxRelayListNote().flow().metadata.stateFlow
}
fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? {
return getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent
}
fun savePrivateOutboxRelayList(relays: List<String>) {
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 { fun getSearchRelayListNote(): AddressableNote {
return LocalCache.getOrCreateAddressableNote( return LocalCache.getOrCreateAddressableNote(
SearchRelayListEvent.createAddressATag(signer.pubKey), SearchRelayListEvent.createAddressATag(signer.pubKey),

View File

@@ -102,6 +102,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RecommendRelayEvent import com.vitorpamplona.quartz.events.RecommendRelayEvent
import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.RelaySetEvent
@@ -929,6 +930,13 @@ object LocalCache {
consumeBaseReplaceable(event, relay) consumeBaseReplaceable(event, relay)
} }
private fun consume(
event: PrivateOutboxRelayListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume( private fun consume(
event: SearchRelayListEvent, event: SearchRelayListEvent,
relay: Relay?, relay: Relay?,
@@ -2565,6 +2573,7 @@ object LocalCache {
is NNSEvent -> comsume(event, relay) is NNSEvent -> comsume(event, relay)
is OtsEvent -> consume(event, relay) is OtsEvent -> consume(event, relay)
is PrivateDmEvent -> consume(event, relay) is PrivateDmEvent -> consume(event, relay)
is PrivateOutboxRelayListEvent -> consume(event, relay)
is PinListEvent -> consume(event, relay) is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay) is PeopleListEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay) is PollNoteEvent -> consume(event, relay)

View File

@@ -58,6 +58,7 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
@@ -104,7 +105,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = filter =
JsonFilter( 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), authors = listOf(account.userProfile().pubkeyHex),
limit = 10, limit = 10,
), ),
@@ -123,6 +124,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
ContactListEvent.KIND, ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND, AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND, ChatMessageRelayListEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
SearchRelayListEvent.KIND, SearchRelayListEvent.KIND,
FileServersEvent.KIND, FileServersEvent.KIND,
MuteListEvent.KIND, MuteListEvent.KIND,
@@ -285,6 +287,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
if (LocalCache.justVerify(event)) { if (LocalCache.justVerify(event)) {
when (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 -> { is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist. // Avoid decrypting over and over again if the event already exist.

View File

@@ -74,6 +74,9 @@ fun AllRelayListView(
val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle() val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle() val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle()
val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel()
val privateOutboxFeedState by privateOutboxViewModel.relays.collectAsStateWithLifecycle()
val searchViewModel: SearchRelayListViewModel = viewModel() val searchViewModel: SearchRelayListViewModel = viewModel()
val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle() val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle()
@@ -86,6 +89,7 @@ fun AllRelayListView(
nip65ViewModel.load(accountViewModel.account) nip65ViewModel.load(accountViewModel.account)
searchViewModel.load(accountViewModel.account) searchViewModel.load(accountViewModel.account)
localViewModel.load(accountViewModel.account) localViewModel.load(accountViewModel.account)
privateOutboxViewModel.load(accountViewModel.account)
} }
Dialog( Dialog(
@@ -115,6 +119,7 @@ fun AllRelayListView(
nip65ViewModel.create() nip65ViewModel.create()
searchViewModel.create() searchViewModel.create()
localViewModel.create() localViewModel.create()
privateOutboxViewModel.create()
onClose() onClose()
}, },
true, true,
@@ -127,6 +132,11 @@ fun AllRelayListView(
CloseButton( CloseButton(
onPress = { onPress = {
kind3ViewModel.clear() kind3ViewModel.clear()
dmViewModel.clear()
nip65ViewModel.clear()
searchViewModel.clear()
localViewModel.clear()
privateOutboxViewModel.clear()
onClose() onClose()
}, },
) )
@@ -177,6 +187,14 @@ fun AllRelayListView(
} }
renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav) 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 { item {
SettingsCategoryWithButton( SettingsCategoryWithButton(
stringResource(R.string.search_section), stringResource(R.string.search_section),

View File

@@ -59,7 +59,7 @@ fun LazyListScope.renderLocalItems(
onClose: () -> Unit, onClose: () -> Unit,
nav: (String) -> Unit, nav: (String) -> Unit,
) { ) {
itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
onDelete = { postViewModel.deleteRelay(item) }, onDelete = { postViewModel.deleteRelay(item) },

View File

@@ -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<BasicRelaySetupInfo>,
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) }
}
}

View File

@@ -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<List<BasicRelaySetupInfo>>(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)) }
}
}

View File

@@ -825,6 +825,8 @@
<string name="public_notif_section_explainer">This relay type receives all replies, comments, likes and zaps to your posts. Insert between 13 relays and make sure they accept posts from anyone.</string> <string name="public_notif_section_explainer">This relay type receives all replies, comments, likes and zaps to your posts. Insert between 13 relays and make sure they accept posts from anyone.</string>
<string name="private_inbox_section">DM Inbox Relays</string> <string name="private_inbox_section">DM Inbox Relays</string>
<string name="private_inbox_section_explainer">Insert between 13 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)</string> <string name="private_inbox_section_explainer">Insert between 13 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)</string>
<string name="private_outbox_section">Private Relays</string>
<string name="private_outbox_section_explainer">Insert between 13 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.</string>
<string name="kind_3_section">General Relays</string> <string name="kind_3_section">General Relays</string>
<string name="kind_3_section_description">Amethyst uses these relays to download posts for you.</string> <string name="kind_3_section_description">Amethyst uses these relays to download posts for you.</string>
<string name="search_section">Search Relays</string> <string name="search_section">Search Relays</string>

View File

@@ -124,6 +124,7 @@ class EventFactory {
PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.KIND -> PrivateDmEvent(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) ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig) RecommendRelayEvent.KIND -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig)
RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig)

View File

@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun dTag() = FIXED_D_TAG
fun relays(): List<String>? {
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<Array<String>>? {
return privateTagsCache
}
fun privateTags(
signer: NostrSigner,
onReady: (Array<Array<String>>) -> Unit,
) {
if (content.isEmpty()) {
onReady(emptyArray())
return
}
privateTagsCache?.let {
onReady(it)
return
}
try {
signer.nip44Decrypt(content, pubKey) {
privateTagsCache = mapper.readValue<Array<Array<String>>>(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<Array<String>>? = null,
signer: NostrSigner,
onReady: (String) -> Unit,
) {
val msg = mapper.writeValueAsString(privateTags)
signer.nip44Encrypt(
msg,
signer.pubKey,
onReady,
)
}
fun createTagArray(relays: List<String>): Array<Array<String>> {
return relays.map {
arrayOf("relay", it)
}.toTypedArray()
}
fun updateRelayList(
earlierVersion: PrivateOutboxRelayListEvent,
relays: List<String>,
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<PrivateOutboxRelayListEvent>(createdAt, KIND, TAGS, it) {
it.privateTagsCache = tags
onReady(it)
}
}
}
fun createFromScratch(
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PrivateOutboxRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<String>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (PrivateOutboxRelayListEvent) -> Unit,
) {
val privateTagArray = createTagArray(relays)
encryptTags(privateTagArray, signer) { privateTags ->
signer.sign<PrivateOutboxRelayListEvent>(createdAt, KIND, TAGS, privateTags) {
it.privateTagsCache = privateTagArray
onReady(it)
}
}
}
}
}