3rd large migration to outbox.

This commit is contained in:
Vitor Pamplona
2025-07-01 20:38:18 -04:00
parent e10332c957
commit 0cfcfaf899
624 changed files with 19405 additions and 11339 deletions
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinNotebookOptionsProvider">
<option name="shouldAddProjectLibrariesToClasspath" value="true" />
</component>
</project>
+6
View File
@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JsCompilerArguments">
<option name="moduleKind" value="plain" />
</component>
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.0" />
</component>
+1 -1
View File
@@ -174,7 +174,7 @@ Lastly, the user's account information (private key/pub key) is stored in the An
## Setup
Make sure to have the following pre-requisites installed:
1. Java 17+
1. Java 21+
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
@@ -37,17 +37,19 @@ import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor
import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -89,18 +91,26 @@ class Amethyst : Application() {
scope = applicationIOScope,
)
// Connects the NostrClient class with okHttp
val factory =
OkHttpWebSocket.BuilderFactory { _, useProxy ->
okHttpClients.getHttpClient(useProxy)
}
val torProxySettingsAnchor = ProxySettingsAnchor()
// Provides a relay pool
val client: NostrClient = NostrClient(factory)
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
okHttpClients.getHttpClient(torProxySettingsAnchor.useProxy(url))
}
// Caches all events in Memory
val cache: LocalCache = LocalCache
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// Provides a relay pool
val client: NostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Watches for changes on Tor and Relay List Settings
val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, applicationIOScope)
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
@@ -112,15 +122,9 @@ class Amethyst : Application() {
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
// Trash.
val serviceManager = ServiceManager(client, applicationIOScope)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(this) }
@@ -33,9 +33,9 @@ import kotlin.time.measureTimedValue
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
fun debugState(context: Context) {
Amethyst.instance.client
.allSubscriptions()
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") }
// Amethyst.instance.client
// .allSubscriptions()
// .forEach { Log.d("STATE DUMP", "${it.key} ${it.value.filters.joinToString { it.filter.toJson() }}") }
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
@@ -27,16 +27,15 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -53,7 +52,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -82,7 +84,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val RELAYS = "relays"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
@@ -99,6 +100,9 @@ private object PrefKeys {
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData"
const val LATEST_CHANNEL_LIST = "latestChannelList"
const val LATEST_COMMUNITY_LIST = "latestCommunityList"
const val LATEST_HASHTAG_LIST = "latestHashtagList"
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
@@ -120,8 +124,8 @@ object LocalPreferences {
private const val COMMA = ","
private var currentAccount: String? = null
private var savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
private val savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
private val cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
suspend fun currentAccount(): String? {
if (currentAccount == null) {
@@ -249,7 +253,7 @@ object LocalPreferences {
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
return Amethyst.instance.encryptedStorage(npub)
Amethyst.instance.encryptedStorage(npub)
}
}
@@ -299,7 +303,6 @@ object LocalPreferences {
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
}
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, EventMapper.mapper.writeValueAsString(settings.localRelays))
putString(
PrefKeys.DEFAULT_FILE_SERVER,
@@ -364,8 +367,8 @@ object LocalPreferences {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
if (settings.localRelayServers.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
if (settings.localRelayServers.value.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value)
} else {
remove(PrefKeys.LOCAL_RELAY_SERVERS)
}
@@ -406,6 +409,33 @@ object LocalPreferences {
remove(PrefKeys.LATEST_CHANNEL_LIST)
}
if (settings.backupCommunityList != null) {
putString(
PrefKeys.LATEST_COMMUNITY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupCommunityList),
)
} else {
remove(PrefKeys.LATEST_COMMUNITY_LIST)
}
if (settings.backupHashtagList != null) {
putString(
PrefKeys.LATEST_HASHTAG_LIST,
EventMapper.mapper.writeValueAsString(settings.backupHashtagList),
)
} else {
remove(PrefKeys.LATEST_HASHTAG_LIST)
}
if (settings.backupGeohashList != null) {
putString(
PrefKeys.LATEST_HASHTAG_LIST,
EventMapper.mapper.writeValueAsString(settings.backupGeohashList),
)
} else {
remove(PrefKeys.LATEST_HASHTAG_LIST)
}
if (settings.backupEphemeralChatList != null) {
putString(
PrefKeys.LATEST_EPHEMERAL_LIST,
@@ -453,9 +483,8 @@ object LocalPreferences {
prefs: SharedPreferences = encryptedPreferences(),
) {
Log.d("LocalPreferences", "Saving to shared settings")
with(prefs.edit()) {
prefs.edit {
putString(PrefKeys.SHARED_SETTINGS, EventMapper.mapper.writeValueAsString(sharedSettings))
apply()
}
}
@@ -514,7 +543,7 @@ object LocalPreferences {
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_FOLLOWS
val defaultStoriesFollowList =
getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList =
@@ -522,8 +551,6 @@ object LocalPreferences {
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
@@ -538,8 +565,11 @@ object LocalPreferences {
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
val latestChannelList = parseEventOrNull<ChannelListEvent>(PrefKeys.LATEST_CHANNEL_LIST)
val latestCommunityList = parseEventOrNull<CommunityListEvent>(PrefKeys.LATEST_COMMUNITY_LIST)
val latestHashtagList = parseEventOrNull<HashtagListEvent>(PrefKeys.LATEST_HASHTAG_LIST)
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
@@ -559,14 +589,13 @@ object LocalPreferences {
keyPair = keyPair,
transientAccount = false,
externalSignerPackageName = externalSignerPackageName,
localRelays = localRelays,
localRelayServers = localRelayServers,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
zapPaymentRequest = zapPaymentRequestServer,
zapPaymentRequest = zapPaymentRequestServer?.normalize(),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
@@ -579,6 +608,9 @@ object LocalPreferences {
backupMuteList = latestMuteList,
backupAppSpecificData = latestAppSpecificData,
backupChannelList = latestChannelList,
backupCommunityList = latestCommunityList,
backupHashtagList = latestHashtagList,
backupGeohashList = latestGeohashList,
backupEphemeralChatList = latestEphemeralList,
torSettings = TorSettingsFlow.build(torSettings),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
@@ -1,142 +0,0 @@
/**
* 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
import android.util.Log
import androidx.compose.runtime.Stable
import coil3.annotation.DelicateCoilApi
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.relays.NostrClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Stable
class ServiceManager(
val client: NostrClient,
val scope: CoroutineScope,
) {
// to not open amber in a loop trying to use auth relays and registering for notifications
private var isStarted: Boolean = false
private var account: Account? = null
private var collectorJob: Job? = null
private fun start(account: Account) {
this.account = account
start()
}
@OptIn(DelicateCoilApi::class)
private fun start() {
Log.d("ServiceManager", "-- May Start (hasStarted: $isStarted) for account $account")
if (isStarted && account != null) {
Log.d("ServiceManager", "---- Restarting innactive relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}")
client.reconnect()
return
}
Log.d("ServiceManager", "---- Starting Relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}")
val myAccount = account
if (myAccount != null) {
val relaySet = myAccount.connectToRelaysWithProxy.value
client.reconnect(relaySet)
collectorJob?.cancel()
collectorJob = null
collectorJob =
scope.launch {
myAccount.connectToRelaysWithProxy.collectLatest {
delay(500)
if (isStarted) {
client.reconnect(it, onlyIfChanged = true)
}
}
}
isStarted = true
}
}
private fun pause() {
Log.d("ServiceManager", "-- Pausing Relay Services")
collectorJob?.cancel()
collectorJob = null
client.reconnect(null)
isStarted = false
}
fun cleanObservers() {
LocalCache.cleanObservers()
}
// This method keeps the pause/start in a Syncronized block to
// avoid concurrent pauses and starts.
@Synchronized
fun forceRestart(
account: Account? = null,
start: Boolean = true,
pause: Boolean = true,
) {
Log.d("ServiceManager", "-- Force Restart (start:$start) (pause:$pause) for $account")
if (pause) {
pause()
}
if (start) {
if (account != null) {
start(account)
} else {
start()
}
}
}
fun setAccountAndRestart(account: Account) {
forceRestart(account, true, true)
}
fun forceRestart() {
forceRestart(null, true, true)
}
fun justStartIfItHasAccount() {
if (account != null) {
forceRestart(null, true, false)
}
}
fun pauseForGood() {
forceRestart(null, false, true)
}
fun pauseAndLogOff() {
account = null
forceRestart(null, false, true)
}
}
File diff suppressed because it is too large Load Diff
@@ -27,8 +27,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -43,11 +41,15 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -67,38 +69,29 @@ val DefaultChannelSet =
val DefaultChannels =
listOf(
// Anigma's Nostr
EventIdHint("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", "wss://nos.lol"),
EventIdHint("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos),
// Amethyst's Group
EventIdHint("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", "wss://nos.lol"),
EventIdHint("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos),
)
val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner)
val DefaultNIP65List =
listOf(
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH),
)
val DefaultDMRelayList =
listOf(
RelayUrlFormatter.normalize("wss://auth.nostr1.com"),
RelayUrlFormatter.normalize("wss://relay.0xchat.com"),
RelayUrlFormatter.normalize("wss://nos.lol"),
)
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
val DefaultSearchRelayList =
listOf(
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
RelayUrlFormatter.normalize("wss://search.nos.today"),
)
val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday)
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val GLOBAL_FOLLOWS = " Global "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val KIND3_FOLLOWS = " All Follows "
val ALL_FOLLOWS = " All Follows "
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@@ -108,14 +101,13 @@ class AccountSettings(
val keyPair: KeyPair,
val transientAccount: Boolean = false,
var externalSignerPackageName: String? = null,
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var localRelayServers: Set<String> = setOf(),
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(ALL_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
var zapPaymentRequest: Nip47WalletConnect.Nip47URINorm? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -128,6 +120,9 @@ class AccountSettings(
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
var backupAppSpecificData: AppSpecificDataEvent? = null,
var backupChannelList: ChannelListEvent? = null,
var backupCommunityList: CommunityListEvent? = null,
var backupHashtagList: HashtagListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
val torSettings: TorSettingsFlow = TorSettingsFlow(),
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
@@ -200,7 +195,7 @@ class AccountSettings(
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean {
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean {
if (zapPaymentRequest != newServer) {
zapPaymentRequest = newServer
saveAccountSettings()
@@ -256,11 +251,11 @@ class AccountSettings(
// proxy settings
// ---
fun setTorSettings(newTorSettings: TorSettings): Boolean {
if (torSettings.update(newTorSettings)) {
return if (torSettings.update(newTorSettings)) {
saveAccountSettings()
return true
true
} else {
return false
false
}
}
@@ -301,8 +296,8 @@ class AccountSettings(
// ----
fun updateLocalRelayServers(servers: Set<String>) {
if (localRelayServers != servers) {
localRelayServers = servers
if (localRelayServers.value != servers) {
localRelayServers.update { servers }
saveAccountSettings()
}
}
@@ -377,6 +372,36 @@ class AccountSettings(
}
}
fun updateGeohashListTo(newGeohashList: GeohashListEvent?) {
if (newGeohashList == null || newGeohashList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupGeohashList?.id != newGeohashList.id) {
backupGeohashList = newGeohashList
saveAccountSettings()
}
}
fun updateHashtagListTo(newHashtagList: HashtagListEvent?) {
if (newHashtagList == null || newHashtagList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupHashtagList?.id != newHashtagList.id) {
backupHashtagList = newHashtagList
saveAccountSettings()
}
}
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupCommunityList?.id != newCommunityList.id) {
backupCommunityList = newCommunityList
saveAccountSettings()
}
}
fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
@@ -492,17 +517,6 @@ class AccountSettings(
}
}
// ----
// local relays
// ----
fun updateLocalRelays(newLocalRelays: Set<RelaySetupInfo>) {
if (!localRelays.equals(newLocalRelays)) {
localRelays = newLocalRelays
saveAccountSettings()
}
}
// ---
// attestations
// ---
@@ -24,10 +24,10 @@ import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,7 +45,7 @@ class AntiSpamFilter {
fun isSpam(
event: Event,
relay: RelayBriefInfoCache.RelayBriefInfo?,
relay: NormalizedRelayUrl?,
): Boolean {
checkNotInMainThread()
@@ -74,14 +74,14 @@ class AntiSpamFilter {
) {
Log.w(
"Potential SPAM Message",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} $relay ${event.content.replace("\n", " | ")}",
)
// Log down offenders
logOffender(hash, event)
if (relay != null) {
RelayStats.newSpam(relay.url, njumpLink(NEvent.create(event.id, event.pubKey, event.kind, relay.url)))
RelayStats.newSpam(relay, njumpLink(NEvent.create(event.id, event.pubKey, event.kind, relay)))
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.toNEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.LargeCache
@@ -51,7 +51,7 @@ class EphemeralChatChannel(
override fun idDisplayNote() = idNote().toShortenHex()
override fun relays() = listOf(roomId.relayUrl)
override fun relays() = setOf(roomId.relayUrl)
override fun toBestDisplayName() = roomId.toDisplayKey()
@@ -68,17 +68,21 @@ class PublicChatChannel(
) : Channel(idHex) {
var event: ChannelCreateEvent? = null
var infoTags = EmptyTagList
var info = ChannelData(null, null, null, null)
var info = ChannelDataNorm(null, null, null, null)
override fun relays() = info.relays ?: super.relays()
override fun relays() = info.relays?.toSet() ?: super.relays()
fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, *relays().toTypedArray())
fun relayHintUrls() = relays().take(3)
fun relayHintUrl() = relays().firstOrNull()
fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, relayHintUrls())
fun toNostrUri() = "nostr:${toNEvent()}"
fun toEventHint() = event?.let { EventHintBundle<ChannelCreateEvent>(it, relays().firstOrNull(), null) }
fun toEventHint() = event?.let { EventHintBundle(it, relayHintUrl(), null) }
fun toEventId() = EventIdHint(idHex, relays().firstOrNull())
fun toEventId() = EventIdHintOptional(idHex, relayHintUrl())
fun updateChannelInfo(
creator: User,
@@ -99,7 +103,7 @@ class PublicChatChannel(
fun updateChannelInfo(
creator: User,
channelInfo: ChannelData,
channelInfo: ChannelDataNorm,
updatedAt: Long,
) {
this.info = channelInfo
@@ -130,10 +134,12 @@ class LiveActivitiesChannel(
fun address() = address
override fun relays() = info?.allRelayUrls() ?: super.relays()
override fun relays() = info?.allRelayUrls()?.toSet() ?: super.relays()
fun relayHintUrl() = relays().firstOrNull()
fun relayHintUrls() = relays().take(3)
fun updateChannelInfo(
creator: User,
channelInfo: LiveActivitiesEvent,
@@ -150,11 +156,10 @@ class LiveActivitiesChannel(
override fun profilePicture(): String? = info?.image()?.ifBlank { null }
override fun anyNameStartsWith(prefix: String): Boolean =
listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }
.isNotEmpty()
info?.title()?.contains(prefix, true) == true ||
info?.summary()?.contains(prefix, true) == true
fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, *relays().toTypedArray())
fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrls())
fun toATag() = ATag(address, relayHintUrl())
@@ -173,7 +178,8 @@ abstract class Channel(
var updatedMetadataAt: Long = 0
val notes = LargeCache<HexKey, Note>()
var lastNoteCreatedAt: Long = 0
private var relays = mapOf<RelayBriefInfoCache.RelayBriefInfo, Counter>()
private var relays = mapOf<NormalizedRelayUrl, Counter>()
open fun idNote() = Hex.decode(idHex).toNEvent()
@@ -187,13 +193,13 @@ abstract class Channel(
open fun profilePicture(): String? = creator?.info?.banner
open fun relays() =
open fun relays(): Set<NormalizedRelayUrl> =
relays.keys
.toSortedSet { o1, o2 ->
val o1Count = relays[o1]?.number ?: 0
val o2Count = relays[o2]?.number ?: 0
o2Count.compareTo(o1Count) // descending
}.map { it.url }
}
open fun updateChannelInfo(
creator: User,
@@ -206,13 +212,13 @@ abstract class Channel(
}
@Synchronized
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
fun addRelaySync(briefInfo: NormalizedRelayUrl) {
if (briefInfo !in relays) {
relays = relays + Pair(briefInfo, Counter(1))
}
}
fun addRelay(relay: RelayBriefInfoCache.RelayBriefInfo) {
fun addRelay(relay: NormalizedRelayUrl) {
val counter = relays[relay]
if (counter != null) {
counter.number++
@@ -223,7 +229,7 @@ abstract class Channel(
fun addNote(
note: Note,
relay: RelayBriefInfoCache.RelayBriefInfo? = null,
relay: NormalizedRelayUrl? = null,
) {
notes.put(note.idHex, note)
@@ -0,0 +1,49 @@
/**
* 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.model
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
object Constants {
val nos = RelayUrlNormalizer.normalize("wss://nos.lol")
val mom = RelayUrlNormalizer.normalize("wss://nostr.mom")
val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net")
val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val wine = RelayUrlNormalizer.normalize("wss://nostr.wine")
val band = RelayUrlNormalizer.normalize("wss://relay.nostr.band")
val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com")
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social")
val bg = RelayUrlNormalizer.normalize("wss://relay.nostr.bg")
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
val fmtwiz = RelayUrlNormalizer.normalize("wss://nostr.fmt.wiz.biz")
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz, bg)
val defaultSearchRelaySet = setOf(band, wine, where)
}
@@ -50,19 +50,21 @@ import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
@Preview
@Composable
fun RenderHashTagIconsPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList,
) { word, state ->
when (word) {
is HashTagSegment -> HashTag(word, EmptyNav)
is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav)
is RegularTextSegment -> Text(word.segmentText)
}
}
File diff suppressed because it is too large Load Diff
@@ -22,13 +22,11 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -37,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
@@ -74,6 +73,8 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import com.vitorpamplona.quartz.utils.launchAndWaitAll
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -157,7 +158,7 @@ open class Note(
var zapPayments = mapOf<Note, Note?>()
private set
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
var relays = listOf<NormalizedRelayUrl>()
private set
fun id() = Hex.decode(idHex)
@@ -183,14 +184,26 @@ open class Note(
}
}
fun relayHintUrl(): String? {
fun relayUrls(): List<NormalizedRelayUrl> {
val authorRelay = author?.relayHints()?.ifEmpty { null }
return authorRelay ?: relays
}
fun relayUrlsForReactions(): List<NormalizedRelayUrl> {
val authorRelay = author?.inboxRelays()?.ifEmpty { null }
return authorRelay ?: relays
}
fun relayHintUrl(): NormalizedRelayUrl? {
val authorRelay = author?.latestMetadataRelay
return if (relays.isNotEmpty()) {
if (authorRelay != null && relays.any { it.url == authorRelay }) {
if (authorRelay != null && relays.any { it == authorRelay }) {
authorRelay
} else {
relays.firstOrNull()?.url
relays.firstOrNull()
}
} else {
null
@@ -293,7 +306,7 @@ open class Note(
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
zapsAmount = BigDecimal.ZERO
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
relays = listOf<NormalizedRelayUrl>()
if (repliesChanged) flowSet?.replies?.invalidateData()
if (reactionsChanged) flowSet?.reactions?.invalidateData()
@@ -445,17 +458,17 @@ open class Note(
}
@Synchronized
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
if (briefInfo !in relays) {
relays = relays + briefInfo
fun addRelaySync(relay: NormalizedRelayUrl) {
if (relay !in relays) {
relays = relays + relay
}
}
fun hasRelay(relay: RelayBriefInfoCache.RelayBriefInfo) = relay !in relays
fun hasRelay(relay: NormalizedRelayUrl) = relay !in relays
fun addRelay(brief: RelayBriefInfoCache.RelayBriefInfo) {
if (brief !in relays) {
addRelaySync(brief)
fun addRelay(relay: NormalizedRelayUrl) {
if (relay !in relays) {
addRelaySync(relay)
flowSet?.relays?.invalidateData()
}
}
@@ -779,9 +792,17 @@ open class Note(
fun reactedBy(loggedIn: User): List<String> = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
return boosts.firstOrNull {
it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo()
} != null // 5 minute protection
val fiveMinsAgo = TimeUtils.fiveMinutesAgo()
return boosts.any {
it.author == loggedIn && (it.createdAt() ?: 0) > fiveMinsAgo
}
}
fun hasBoostedInTheLast5Minutes(loggedIn: HexKey): Boolean {
val fiveMinsAgo = TimeUtils.fiveMinutesAgo()
return boosts.any {
(it.createdAt() ?: 0) > fiveMinsAgo && it.author?.pubkeyHex == loggedIn
}
}
fun boostedBy(loggedIn: User): List<Note> = boosts.filter { it.author == loggedIn }
@@ -823,7 +844,7 @@ open class Note(
zapsAmount = BigDecimal.ZERO
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
fun isHiddenFor(accountChoices: HiddenUsersState.LiveHiddenUsers): Boolean {
val thisEvent = event ?: return false
val hash = thisEvent.pubKey.hashCode()
@@ -24,11 +24,11 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache.RelayBriefInfo
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
@@ -57,7 +58,7 @@ class User(
var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null
var latestMetadataRelay: String? = null
var latestMetadataRelay: NormalizedRelayUrl? = null
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
@@ -67,7 +68,7 @@ class User(
var zaps = mapOf<Note, Note?>()
private set
var relaysBeingUsed = mapOf<String, RelayInfo>()
var relaysBeingUsed = mapOf<NormalizedRelayUrl, RelayInfo>()
private set
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
@@ -79,13 +80,21 @@ class User(
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun dmInboxRelayList() = (LocalCache.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(pubkeyHex))?.event as? ChatMessageRelayListEvent)
fun authorRelayList() = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent)
fun toNProfile() = NProfile.create(pubkeyHex, relayHints())
fun relayHints() = authorRelayList()?.writeRelays()?.take(3) ?: listOfNotNull(latestMetadataRelay)
fun outboxRelays() = authorRelayList()?.writeRelaysNorm() ?: listOfNotNull(latestMetadataRelay)
fun bestRelayHint() = authorRelayList()?.writeRelays()?.firstOrNull() ?: latestMetadataRelay
fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay)
fun inboxRelays() = authorRelayList()?.readRelaysNorm() ?: listOfNotNull(latestMetadataRelay)
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay
fun toPTag() = PTag(pubkeyHex, bestRelayHint())
@@ -289,12 +298,12 @@ class User(
}
fun addRelayBeingUsed(
relay: RelayBriefInfo,
relay: NormalizedRelayUrl,
eventTime: Long,
) {
val here = relaysBeingUsed[relay.url]
val here = relaysBeingUsed[relay]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1))
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
@@ -345,8 +354,7 @@ class User(
type: ReportType,
): Boolean =
reports[loggedIn]?.firstOrNull {
it.event is ReportEvent &&
(it.event as ReportEvent).reportedAuthor().any { it.type == type }
(it.event as? ReportEvent)?.reportedAuthor()?.any { it.type == type } ?: false
} != null
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
@@ -445,7 +453,7 @@ class UserFlowSet(
@Immutable
data class RelayInfo(
val url: String,
val url: NormalizedRelayUrl,
var lastEvent: Long,
var counter: Long,
)
@@ -0,0 +1,117 @@
/**
* 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.model.edits
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
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 PrivateStorageRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getPrivateOutboxRelayListAddress() = PrivateOutboxRelayListEvent.createAddress(signer.pubKey)
fun getPrivateOutboxRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getPrivateOutboxRelayListAddress())
fun getPrivateOutboxRelayListFlow(): StateFlow<NoteState> = getPrivateOutboxRelayListNote().flow().metadata.stateFlow
fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent
fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList
return event?.relays()?.toSet() ?: emptySet()
}
val flow =
getPrivateOutboxRelayListFlow()
.map { normalizePrivateOutboxRelayListWithBackup(it.note) }
.onStart { emit(normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
fun saveRelayList(
relays: List<NormalizedRelayUrl>,
onDone: (PrivateOutboxRelayListEvent) -> Unit,
) {
val relayListForPrivateOutbox = getPrivateOutboxRelayList()
if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) {
PrivateOutboxRelayListEvent.updateRelayList(
earlierVersion = relayListForPrivateOutbox,
relays = relays,
signer = signer,
onReady = onDone,
)
} else {
PrivateOutboxRelayListEvent.createFromScratch(
relays = relays,
signer = signer,
onReady = onDone,
)
}
}
init {
settings.backupPrivateHomeRelayList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start")
getPrivateOutboxRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}")
(it.note.event as? PrivateOutboxRelayListEvent)?.let {
settings.updatePrivateHomeRelayList(it)
}
}
}
}
}
@@ -20,29 +20,36 @@
*/
package com.vitorpamplona.amethyst.model.emphChat
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlin.coroutines.resume
class EphemeralChatListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getEphemeralChatListAddress() = EphemeralChatListEvent.createAddress(signer.pubKey)
@@ -52,31 +59,27 @@ class EphemeralChatListState(
fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent
suspend fun ephemeralChatListWithBackup(note: Note): Set<RoomId> {
return ephemeralChatList(
note.event as? EphemeralChatListEvent ?: settings.backupEphemeralChatList,
)
}
suspend fun ephemeralChatList(event: EphemeralChatListEvent?): Set<RoomId> {
return tryAndWait { continuation ->
event?.publicAndPrivateRoomIds(signer) {
continuation.resume(it)
}
} ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveEphemeralChatList: StateFlow<Set<RoomId>> by lazy {
getEphemeralChatListFlow()
.transformLatest { noteState ->
val set =
tryAndWait { continuation ->
(noteState.note.event as? EphemeralChatListEvent)?.publicAndPrivateRoomIds(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
emit(ephemeralChatListWithBackup(noteState.note))
}.onStart {
val set =
tryAndWait { continuation ->
getEphemeralChatList()?.publicAndPrivateRoomIds(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
emit(ephemeralChatListWithBackup(getEphemeralChatListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
@@ -89,6 +92,7 @@ class EphemeralChatListState(
channel: EphemeralChatChannel,
onDone: (EphemeralChatListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val ephemeralChatList = getEphemeralChatList()
if (ephemeralChatList == null) {
@@ -113,6 +117,7 @@ class EphemeralChatListState(
channel: EphemeralChatChannel,
onDone: (EphemeralChatListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val ephemeralChatList = getEphemeralChatList()
if (ephemeralChatList != null) {
@@ -125,4 +130,26 @@ class EphemeralChatListState(
)
}
}
init {
settings.backupEphemeralChatList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start")
getEphemeralChatListFlow().collect {
Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}")
(it.note.event as? EphemeralChatListEvent)?.let {
settings.updateEphemeralChatListTo(it)
}
}
}
}
}
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.localRelays
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class LocalRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun normalizeLocalRelayListWithBackup(relayList: Set<String>): Set<NormalizedRelayUrl> {
return relayList.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
}
val flow =
settings.localRelayServers
.map { normalizeLocalRelayListWithBackup(it) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
normalizeLocalRelayListWithBackup(settings.localRelayServers.value),
)
fun saveRelayList(
relays: List<NormalizedRelayUrl>,
onDone: () -> Unit,
) {
settings.updateLocalRelayServers(relays.map { it.url }.toSet())
onDone()
}
}
@@ -0,0 +1,55 @@
/**
* 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.model.nip01UserMetadata
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlin.collections.plus
class AccountOutboxRelayState(
nip65: Nip65RelayListState,
privateStorage: PrivateStorageRelayListState,
local: LocalRelayListState,
scope: CoroutineScope,
) {
val flow =
combine(
nip65.outboxFlow,
privateStorage.flow,
local.flow,
) { nip65Inbox, privateOutBox, localRelays ->
nip65Inbox + privateOutBox + localRelays
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
nip65.outboxFlow.value +
privateStorage.flow.value +
local.flow.value,
)
}
@@ -18,39 +18,32 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relays
package com.vitorpamplona.amethyst.model.nip01UserMetadata
import android.app.Application
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
/**
* There should be only one instance of the Tor binding per app.
*
* Tor will connect as soon as status is listened to.
*/
class RelayManager(
app: Application,
class NotificationInboxRelayState(
nip65RelayList: Nip65RelayListState,
localRelayList: LocalRelayListState,
scope: CoroutineScope,
torManager: TorManager,
connManager: ConnectivityManager,
) {
val relayService =
val flow =
combine(
torManager.status,
connManager.status,
) { torStatus, connManager ->
}
val status: StateFlow<RelayServiceStatus> =
RelayService(app).status.stateIn(
scope,
SharingStarted.WhileSubscribed(30000),
RelayServiceStatus.Off,
)
nip65RelayList.inboxFlow,
localRelayList.flow,
) { nip65Inbox, localRelays ->
nip65Inbox + localRelays
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
nip65RelayList.inboxFlow.value + localRelayList.flow.value,
)
}
@@ -0,0 +1,124 @@
/**
* 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.model.nip01UserMetadata
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class UserMetadataState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
// fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey)
fun getUserMetadataUser(): User = cache.getOrCreateUser(signer.pubKey)
fun getUserMetadataFlow(): StateFlow<UserState> = getUserMetadataUser().flow().metadata.stateFlow
fun getUserMetadataEvent(): MetadataEvent? = getUserMetadataUser().latestMetadata
fun sendNewUserMetadata(
name: String? = null,
picture: String? = null,
banner: String? = null,
website: String? = null,
pronouns: String? = null,
about: String? = null,
nip05: String? = null,
lnAddress: String? = null,
lnURL: String? = null,
twitter: String? = null,
mastodon: String? = null,
github: String? = null,
onDone: (MetadataEvent) -> Unit,
) {
val latest = getUserMetadataEvent()
val template =
if (latest != null) {
MetadataEvent.updateFromPast(
latest = latest,
name = name,
displayName = name,
picture = picture,
banner = banner,
website = website,
pronouns = pronouns,
about = about,
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
twitter = twitter,
mastodon = mastodon,
github = github,
)
} else {
MetadataEvent.createNew(
name = name,
displayName = name,
picture = picture,
banner = banner,
website = website,
pronouns = pronouns,
about = about,
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
twitter = twitter,
mastodon = mastodon,
github = github,
)
}
signer.sign(template, onDone)
}
init {
settings.backupUserMetadata?.let {
Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
// saves contact list for the next time.
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Kind 0 Collector Start")
getUserMetadataFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 0 ${it.user.toBestDisplayName()}")
settings.updateUserMetadata(it.user.latestMetadata)
}
}
}
}
@@ -0,0 +1,100 @@
/**
* 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.model.nip02FollowLists
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CoroutineScope
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.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlin.collections.map
import kotlin.collections.toSet
class FollowListOutboxRelays(
kind3Follows: FollowListState,
val cache: LocalCache,
scope: CoroutineScope,
) {
fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey)
fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey))
fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
fun combineAllFlows(flows: List<StateFlow<NoteState>>): Flow<Set<NormalizedRelayUrl>> =
combine(flows) { relayListNotes: Array<NoteState> ->
relayListNotes.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()
}
}.map {
it.flatten().toSet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<NormalizedRelayUrl>> =
kind3Follows.flow.transformLatest { followList ->
val flows: List<StateFlow<NoteState>> = allRelayListFlows(followList.authors)
val relayListFlows = combineAllFlows(flows)
emitAll(relayListFlows)
}.onStart {
kind3Follows.flow.value.authors.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()
}.flatten().toSet()
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowSet: StateFlow<Set<String>> =
flow.map { relayList ->
relayList.map { it.url }.toSet()
}.onStart {
kind3Follows.flow.value.authors.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()?.map { it.url }?.toSet()
}.flatten().toSet()
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@@ -0,0 +1,171 @@
/**
* 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.model.nip02FollowLists
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class FollowListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
// fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey)
fun getFollowListUser(): User = cache.getOrCreateUser(signer.pubKey)
fun getFollowListFlow(): StateFlow<UserState> = getFollowListUser().flow().follows.stateFlow
fun getFollowListEvent(): ContactListEvent? = getFollowListUser().latestContactList
@OptIn(ExperimentalCoroutinesApi::class)
private val innerFlow: Flow<Kind3Follows> =
getFollowListFlow().transformLatest {
emit(buildKind3Follows(it.user.latestContactList))
}
val flow =
innerFlow
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
buildKind3Follows(getFollowListEvent() ?: settings.backupContactList),
)
/**
This contains a big OR of everything the user wants to see in the a single feed.
*/
@Immutable
class Kind3Follows(
val authors: Set<String> = emptySet(),
val authorsPlusMe: Set<String>,
val hashtags: Set<String> = emptySet(),
val geotags: Set<String> = emptySet(),
val communities: Set<String> = emptySet(),
) {
val geotagScopes: Set<String> = geotags.mapTo(mutableSetOf<String>()) { GeohashId.toScope(it) }
val hashtagScopes: Set<String> = hashtags.mapTo(mutableSetOf<String>()) { HashtagId.toScope(it) }
}
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,
hashtags =
latestContactList
?.unverifiedFollowTagSet()
?.map { it.lowercase() }
?.toSet() ?: emptySet(),
geotags =
latestContactList
?.geohashes()
?.toSet() ?: emptySet(),
communities =
latestContactList
?.verifiedFollowAddressSet()
?.toSet() ?: emptySet(),
)
}
fun follow(
user: User,
onDone: (ContactListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val contactList = getFollowListEvent()
if (contactList != null) {
ContactListEvent.followUser(contactList, user.pubkeyHex, signer, onReady = onDone)
} else {
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
followTags = emptyList(),
followGeohashes = emptyList(),
followCommunities = emptyList(),
relayUse = emptyMap(),
signer = signer,
onReady = onDone,
)
}
}
fun unfollow(
user: User,
onDone: (ContactListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val contactList = getFollowListEvent()
if (contactList != null && contactList.tags.isNotEmpty()) {
ContactListEvent.unfollowUser(
contactList,
user.pubkeyHex,
signer,
onReady = onDone,
)
}
}
init {
settings.backupContactList?.let {
Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
// saves contact list for the next time.
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
getFollowListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}")
settings.updateContactListTo(it.user.latestContactList)
}
}
}
}
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlinx.coroutines.CoroutineScope
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.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlin.collections.map
class FollowsPerOutboxRelay(
kind3Follows: FollowListState,
val cache: LocalCache,
scope: CoroutineScope,
) {
fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey)
fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey))
fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
fun combineAllFlows(flows: List<StateFlow<NoteState>>): Flow<Map<NormalizedRelayUrl, Set<HexKey>>> =
combine(flows) { relayListNotes: Array<NoteState> ->
mapOfSet {
relayListNotes.forEach { noteState ->
noteState.note.author?.pubkeyHex?.let { authorHex ->
(noteState.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.forEach { relay ->
add(relay, authorHex)
}
}
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
kind3Follows.flow.transformLatest { followList ->
val flows: List<StateFlow<NoteState>> = allRelayListFlows(followList.authors)
val relayListFlows = combineAllFlows(flows)
emitAll(relayListFlows)
}.onStart {
emit(
mapOfSet {
kind3Follows.flow.value.authors.map { authorHex ->
getNIP65RelayList(authorHex)?.writeRelaysNorm()?.forEach { relay ->
add(relay, authorHex)
}
}
},
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyMap(),
)
}
@@ -0,0 +1,59 @@
/**
* 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.model.nip17Dms
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
class DmInboxRelayState(
// main relay
dmRelayList: DmRelayListState,
// backup relays
nip65RelayList: Nip65RelayListState,
privateOutbox: PrivateStorageRelayListState,
localRelayList: LocalRelayListState,
scope: CoroutineScope,
) {
val flow =
combine(
nip65RelayList.inboxFlow,
dmRelayList.flow,
privateOutbox.flow,
localRelayList.flow,
) { nip65Inbox, dmRelayList, privateOutBox, localRelays ->
nip65Inbox + dmRelayList + privateOutBox + localRelays
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
nip65RelayList.inboxFlow.value +
dmRelayList.flow.value +
privateOutbox.flow.value +
localRelayList.flow.value,
)
}
@@ -0,0 +1,114 @@
/**
* 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.model.nip17Dms
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
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 DmRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getDMRelayListAddress() = ChatMessageRelayListEvent.createAddress(signer.pubKey)
fun getDMRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getDMRelayListAddress())
fun getDMRelayListFlow(): StateFlow<NoteState> = getDMRelayListNote().flow().metadata.stateFlow
fun getDMRelayList(): ChatMessageRelayListEvent? = getDMRelayListNote().event as? ChatMessageRelayListEvent
fun normalizeDMRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList
return event?.relays()?.toSet() ?: emptySet()
}
val flow =
getDMRelayListFlow()
.map { normalizeDMRelayListWithBackup(it.note) }
.onStart { emit(normalizeDMRelayListWithBackup(getDMRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
fun saveRelayList(
dmRelays: List<NormalizedRelayUrl>,
onDone: (ChatMessageRelayListEvent) -> Unit,
) {
val relayListForDMs = getDMRelayList()
if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) {
ChatMessageRelayListEvent.updateRelayList(
earlierVersion = relayListForDMs,
relays = dmRelays,
signer = signer,
onReady = onDone,
)
} else {
ChatMessageRelayListEvent.createFromScratch(
relays = dmRelays,
signer = signer,
onReady = onDone,
)
}
}
init {
settings.backupDMRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(it)
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start")
getDMRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating DM Relay List for ${signer.pubKey}")
(it.note.event as? ChatMessageRelayListEvent)?.let {
settings.updateDMRelayList(it)
}
}
}
}
}
@@ -0,0 +1,57 @@
/**
* 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.model.nip18Reposts
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
class RepostAction {
companion object {
fun repost(
note: Note,
signer: NostrSigner,
onDone: (Event) -> Unit,
) {
if (!signer.isWriteable()) return
val noteEvent = note.event ?: return
if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) {
// has already bosted in the past 5mins
return
}
val noteHint = note.relayHintUrl()
val authorHint = note.author?.bestRelayHint()
val template =
if (noteEvent.kind == 1) {
RepostEvent.build(noteEvent, noteHint, authorHint)
} else {
GenericRepostEvent.build(noteEvent, noteHint, authorHint)
}
signer.sign(template, onDone)
}
}
}
@@ -0,0 +1,109 @@
/**
* 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.model.nip25Reactions
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
class ReactionAction {
companion object {
suspend fun reactTo(
note: Note,
reaction: String,
by: User,
signer: NostrSigner,
onPublic: (ReactionEvent) -> Unit,
onPrivate: (NIP17Factory.Result) -> Unit,
) {
if (!signer.isWriteable()) return
if (note.hasReacted(by, reaction)) {
// has already liked this note
return
}
val noteEvent = note.event
if (noteEvent is NIP17Group) {
val users = noteEvent.groupMembers().toList()
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrlTag.decode(reaction)
if (emojiUrl != null) {
note.toEventHint<Event>()?.let {
NIP17Factory().createReactionWithinGroup(
emojiUrl = emojiUrl,
originalNote = it,
to = users,
signer = signer,
) {
onPrivate(it)
}
}
return
}
}
note.toEventHint<Event>()?.let {
NIP17Factory().createReactionWithinGroup(
content = reaction,
originalNote = it,
to = users,
signer = signer,
) {
onPrivate(it)
}
}
return
} else {
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrlTag.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val template = ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl()))
signer.sign(
template,
onReady = onPublic,
)
}
return
}
}
note.toEventHint<Event>()?.let {
signer.sign(
ReactionEvent.build(reaction, it),
onReady = onPublic,
)
}
}
}
}
}
@@ -20,18 +20,23 @@
*/
package com.vitorpamplona.amethyst.model.nip28PublicChats
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -39,12 +44,14 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlin.coroutines.resume
class PublicChatListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey)
@@ -54,31 +61,27 @@ class PublicChatListState(
fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent
suspend fun publicChatListWithBackup(note: Note): Set<EventIdHint> {
return publicChatList(
note.event as? ChannelListEvent ?: settings.backupChannelList,
)
}
suspend fun publicChatList(event: ChannelListEvent?): Set<EventIdHint> {
return tryAndWait { continuation ->
event?.publicAndPrivateChannels(signer) {
continuation.resume(it)
}
} ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val livePublicChatList: StateFlow<Set<EventIdHint>> by lazy {
val flow: StateFlow<Set<EventIdHint>> by lazy {
getChannelListFlow()
.transformLatest { noteState ->
val set =
tryAndWait { continuation ->
(noteState.note.event as? ChannelListEvent)?.publicAndPrivateChannels(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
emit(publicChatListWithBackup(noteState.note))
}.onStart {
val set =
tryAndWait { continuation ->
getChannelList()?.publicAndPrivateChannels(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
emit(publicChatListWithBackup(getChannelListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
@@ -88,8 +91,8 @@ class PublicChatListState(
}
@OptIn(ExperimentalCoroutinesApi::class)
val livePublicChatEventIdSet: StateFlow<Set<HexKey>> by lazy {
livePublicChatList
val flowSet: StateFlow<Set<HexKey>> by lazy {
flow
.map {
it.mapTo(mutableSetOf()) { it.eventId }
}.flowOn(Dispatchers.Default)
@@ -104,6 +107,7 @@ class PublicChatListState(
channel: PublicChatChannel,
onDone: (ChannelListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val publicChatList = getChannelList()
val fullHint = channel.toEventHint()
@@ -127,6 +131,7 @@ class PublicChatListState(
channels: List<PublicChatChannel>,
onDone: (ChannelListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val publicChatList = getChannelList()
val partialHint = channels.map { it.toEventId() }
@@ -141,10 +146,33 @@ class PublicChatListState(
channel: PublicChatChannel,
onDone: (ChannelListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val publicChatList = getChannelList()
if (publicChatList != null) {
ChannelListEvent.removeChannel(publicChatList, channel.idHex, signer, onReady = onDone)
}
}
init {
settings.backupChannelList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Channel List Collector Start")
getChannelListFlow().collect {
Log.d("AccountRegisterObservers", "Channel List for ${signer.pubKey}")
(it.note.event as? ChannelListEvent)?.let {
settings.updateChannelListTo(it)
}
}
}
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip30CustomEmojis
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
@@ -67,7 +68,7 @@ class EmojiPackState(
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveEmojiSelectionPack: StateFlow<List<StateFlow<NoteState>>?> by lazy {
val flow: StateFlow<List<StateFlow<NoteState>>?> by lazy {
getEmojiPackSelectionFlow()
.transformLatest {
emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent))
@@ -98,7 +99,7 @@ class EmojiPackState(
@OptIn(ExperimentalCoroutinesApi::class)
val myEmojis by lazy {
liveEmojiSelectionPack
flow
.transformLatest { emojiList ->
if (emojiList != null) {
emitAll(
@@ -116,4 +117,40 @@ class EmojiPackState(
mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()),
)
}
fun addEmojiPack(
emojiPack: Note,
onDone: (EmojiPackSelectionEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val emojiPackEvent = emojiPack.event
if (emojiPackEvent !is EmojiPackEvent) return
val eventHint = emojiPack.toEventHint<EmojiPackEvent>() ?: return
val usersEmojiList = getEmojiPackSelection()
if (usersEmojiList == null) {
val template = EmojiPackSelectionEvent.build(listOf(eventHint))
signer.sign(template, onDone)
} else {
val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint)
signer.sign(template, onDone)
}
}
fun removeEmojiPack(
emojiPack: Note,
onDone: (EmojiPackSelectionEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val usersEmojiList = getEmojiPackSelection() ?: return
val emojiPackEvent = emojiPack.event
if (emojiPackEvent !is EmojiPackEvent) return
val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent)
signer.sign(template, onDone)
}
}
@@ -0,0 +1,71 @@
/**
* 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.model.nip38UserStatuses
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
class UserStatusAction {
companion object {
fun create(
newStatus: String,
signer: NostrSigner,
onDone: (StatusEvent) -> Unit,
) {
if (!signer.isWriteable()) return
StatusEvent.create(newStatus, "general", expiration = null, signer, onReady = onDone)
}
fun update(
oldStatus: AddressableNote,
newStatus: String,
signer: NostrSigner,
onDone: (StatusEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val oldEvent = oldStatus.event as? StatusEvent ?: return
StatusEvent.update(oldEvent, newStatus, signer, onReady = onDone)
}
fun delete(
oldStatus: AddressableNote,
signer: NostrSigner,
onDone: (Event) -> Unit,
) {
if (!signer.isWriteable()) return
val oldEvent = oldStatus.event as? StatusEvent ?: return
StatusEvent.clear(oldEvent, signer) { event ->
onDone(event)
signer.sign(
DeletionEvent.buildForVersionOnly(listOf(event)),
onReady = onDone,
)
}
}
}
}
@@ -0,0 +1,114 @@
/**
* 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.model.nip50Search
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
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 SearchRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getSearchRelayListAddress() = SearchRelayListEvent.createAddress(signer.pubKey)
fun getSearchRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getSearchRelayListAddress())
fun getSearchRelayListFlow(): StateFlow<NoteState> = getSearchRelayListNote().flow().metadata.stateFlow
fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent
fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList
return event?.relays()?.toSet() ?: DefaultSearchRelayList
}
val flow =
getSearchRelayListFlow()
.map { normalizeSearchRelayListWithBackup(it.note) }
.onStart { emit(normalizeSearchRelayListWithBackup(getSearchRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
fun saveRelayList(
searchRelays: List<NormalizedRelayUrl>,
onDone: (SearchRelayListEvent) -> Unit,
) {
val relayListForSearch = getSearchRelayList()
if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) {
SearchRelayListEvent.updateRelayList(
earlierVersion = relayListForSearch,
relays = searchRelays,
signer = signer,
onReady = onDone,
)
} else {
SearchRelayListEvent.createFromScratch(
relays = searchRelays,
signer = signer,
onReady = onDone,
)
}
}
init {
settings.backupSearchRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Search Relay List Collector Start")
getSearchRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Search Relay List for ${signer.pubKey}")
(it.note.event as? SearchRelayListEvent)?.let {
settings.updateSearchRelayList(it)
}
}
}
}
}
@@ -0,0 +1,158 @@
/**
* 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.model.nip51Lists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CoroutineScope
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 kotlin.coroutines.resume
class BlockPeopleListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
fun getBlockListAddress() = PeopleListEvent.createBlockAddress(signer.pubKey)
fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(getBlockListAddress())
fun getBlockListFlow(): StateFlow<NoteState> = getBlockListNote().flow().metadata.stateFlow
fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent
suspend fun blockListWithBackup(note: Note): PeopleListEvent.UsersAndWords {
return blockList(
note.event as? PeopleListEvent,
)
}
suspend fun blockList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords {
return tryAndWait { continuation ->
event?.publicAndPrivateUsersAndWords(signer) {
continuation.resume(it)
}
} ?: PeopleListEvent.UsersAndWords()
}
val flow =
getBlockListFlow()
.map { blockListWithBackup(it.note) }
.onStart { emit(blockListWithBackup(getBlockListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
PeopleListEvent.UsersAndWords(),
)
fun hideUser(
pubkeyHex: String,
onDone: (PeopleListEvent) -> Unit,
) {
val blockList = getBlockList()
if (blockList != null) {
PeopleListEvent.addUser(
earlierVersion = blockList,
pubKeyHex = pubkeyHex,
isPrivate = true,
signer = signer,
onReady = onDone,
)
} else {
PeopleListEvent.createListWithUser(
name = PeopleListEvent.BLOCK_LIST_D_TAG,
pubKeyHex = pubkeyHex,
isPrivate = true,
signer = signer,
onReady = onDone,
)
}
}
fun showUser(
pubkeyHex: String,
onDone: (PeopleListEvent) -> Unit,
) {
val blockList = getBlockList()
if (blockList != null) {
PeopleListEvent.removeUser(
earlierVersion = blockList,
pubKeyHex = pubkeyHex,
signer = signer,
onReady = onDone,
)
}
}
fun hideWord(
word: String,
onDone: (PeopleListEvent) -> Unit,
) {
val blockList = getBlockList()
if (blockList != null) {
PeopleListEvent.addWord(
earlierVersion = blockList,
word = word,
isPrivate = true,
signer = signer,
onReady = onDone,
)
} else {
PeopleListEvent.createListWithWord(
name = PeopleListEvent.BLOCK_LIST_D_TAG,
word = word,
isPrivate = true,
signer = signer,
onReady = onDone,
)
}
}
fun showWord(
word: String,
onDone: (PeopleListEvent) -> Unit,
) {
val blockList = getBlockList()
if (blockList != null) {
PeopleListEvent.removeWord(
earlierVersion = blockList,
word = word,
signer = signer,
onReady = onDone,
)
}
}
}
@@ -0,0 +1,150 @@
/**
* 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.model.nip51Lists
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlin.coroutines.resume
class GeohashListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getGeohashListAddress() = GeohashListEvent.createAddress(signer.pubKey)
fun getGeohashListNote(): AddressableNote = cache.getOrCreateAddressableNote(getGeohashListAddress())
fun getGeohashListFlow(): StateFlow<NoteState> = getGeohashListNote().flow().metadata.stateFlow
fun getGeohashList(): GeohashListEvent? = getGeohashListNote().event as? GeohashListEvent
suspend fun geohashListWithBackup(note: Note): Set<String> {
return geohashList(
note.event as? GeohashListEvent ?: settings.backupGeohashList,
)
}
suspend fun geohashList(event: GeohashListEvent?): Set<String> {
return tryAndWait { continuation ->
event?.publicAndPrivateGeohash(signer) {
continuation.resume(it)
}
} ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<String>> by lazy {
getGeohashListFlow()
.transformLatest { noteState ->
emit(geohashListWithBackup(noteState.note))
}.onStart {
emit(geohashListWithBackup(getGeohashListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
fun follow(
geohashs: List<String>,
onDone: (GeohashListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val geohashList = getGeohashList()
if (geohashList == null) {
GeohashListEvent.createGeohashs(geohashs, true, signer, onReady = onDone)
} else {
GeohashListEvent.addGeohashs(geohashList, geohashs, true, signer, onReady = onDone)
}
}
fun follow(
geohash: String,
onDone: (GeohashListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val geohashList = getGeohashList()
if (geohashList == null) {
GeohashListEvent.createGeohash(geohash, true, signer, onReady = onDone)
} else {
GeohashListEvent.addGeohash(geohashList, geohash, true, signer, onReady = onDone)
}
}
fun unfollow(
geohash: String,
onDone: (GeohashListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val geohashList = getGeohashList()
if (geohashList != null) {
GeohashListEvent.removeGeohash(geohashList, geohash, signer, onReady = onDone)
}
}
init {
settings.backupGeohashList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Geohash List Collector Start")
getGeohashListFlow().collect {
Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}")
(it.note.event as? GeohashListEvent)?.let {
settings.updateGeohashListTo(it)
}
}
}
}
}
@@ -0,0 +1,150 @@
/**
* 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.model.nip51Lists
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlin.coroutines.resume
class HashtagListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getHashtagListAddress() = HashtagListEvent.createAddress(signer.pubKey)
fun getHashtagListNote(): AddressableNote = cache.getOrCreateAddressableNote(getHashtagListAddress())
fun getHashtagListFlow(): StateFlow<NoteState> = getHashtagListNote().flow().metadata.stateFlow
fun getHashtagList(): HashtagListEvent? = getHashtagListNote().event as? HashtagListEvent
suspend fun hashtagListWithBackup(note: Note): Set<String> {
return hashtagList(
note.event as? HashtagListEvent ?: settings.backupHashtagList,
)
}
suspend fun hashtagList(event: HashtagListEvent?): Set<String> {
return tryAndWait { continuation ->
event?.publicAndPrivateHashtag(signer) {
continuation.resume(it)
}
} ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<String>> by lazy {
getHashtagListFlow()
.transformLatest { noteState ->
emit(hashtagListWithBackup(noteState.note))
}.onStart {
emit(hashtagListWithBackup(getHashtagListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
fun follow(
hashtags: List<String>,
onDone: (HashtagListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val hashtagList = getHashtagList()
if (hashtagList == null) {
HashtagListEvent.createHashtags(hashtags, true, signer, onReady = onDone)
} else {
HashtagListEvent.addHashtags(hashtagList, hashtags, true, signer, onReady = onDone)
}
}
fun follow(
hashtag: String,
onDone: (HashtagListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val hashtagList = getHashtagList()
if (hashtagList == null) {
HashtagListEvent.createHashtag(hashtag, true, signer, onReady = onDone)
} else {
HashtagListEvent.addHashtag(hashtagList, hashtag, true, signer, onReady = onDone)
}
}
fun unfollow(
hashtag: String,
onDone: (HashtagListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val hashtagList = getHashtagList()
if (hashtagList != null) {
HashtagListEvent.removeHashtag(hashtagList, hashtag, signer, onReady = onDone)
}
}
init {
settings.backupHashtagList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Hashtag List Collector Start")
getHashtagListFlow().collect {
Log.d("AccountRegisterObservers", "Hashtag List for ${signer.pubKey}")
(it.note.event as? HashtagListEvent)?.let {
settings.updateHashtagListTo(it)
}
}
}
}
}
@@ -0,0 +1,116 @@
/**
* 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.model.nip51Lists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
class HiddenUsersState(
val muteList: StateFlow<PeopleListEvent.UsersAndWords>,
val blockList: StateFlow<PeopleListEvent.UsersAndWords>,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
var transientHiddenUsers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf())
@Immutable
class LiveHiddenUsers(
val hiddenUsers: Set<String>,
val spammers: Set<String>,
val hiddenWords: Set<String>,
val showSensitiveContent: Boolean?,
) {
// speeds up isHidden calculations
val hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() }
val spammersHashCodes = spammers.mapTo(HashSet()) { it.hashCode() }
val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }
fun isUserHidden(userHex: HexKey) = hiddenUsers.contains(userHex) || spammers.contains(userHex)
}
suspend fun assembleLiveHiddenUsers(
blockList: PeopleListEvent.UsersAndWords,
muteList: PeopleListEvent.UsersAndWords,
transientHiddenUsers: Set<String>,
showSensitiveContent: Boolean?,
): LiveHiddenUsers {
return LiveHiddenUsers(
hiddenUsers = blockList.users + muteList.users,
hiddenWords = blockList.words + muteList.words,
spammers = transientHiddenUsers,
showSensitiveContent = showSensitiveContent,
)
}
val flow: StateFlow<LiveHiddenUsers> by lazy {
combineTransform(
blockList,
muteList,
transientHiddenUsers,
settings.syncedSettings.security.showSensitiveContent,
) { blockList, muteList, transientHiddenUsers, showSensitiveContent ->
checkNotInMainThread()
emit(assembleLiveHiddenUsers(blockList, muteList, transientHiddenUsers, showSensitiveContent))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
runBlocking {
assembleLiveHiddenUsers(
blockList.value,
muteList.value,
transientHiddenUsers.value,
settings.syncedSettings.security.showSensitiveContent.value,
)
},
)
}
fun resetTransientUsers() {
transientHiddenUsers.update {
emptySet()
}
}
fun showUser(pubkeyHex: HexKey) {
transientHiddenUsers.update { it - pubkeyHex }
}
fun hideUser(pubkeyHex: HexKey) {
transientHiddenUsers.update { it + pubkeyHex }
}
fun isHidden(pubkeyHex: HexKey) = pubkeyHex in transientHiddenUsers.value
}
@@ -0,0 +1,185 @@
/**
* 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.model.nip51Lists
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
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
import kotlin.coroutines.resume
class MuteListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getMuteListAddress() = MuteListEvent.createAddress(signer.pubKey)
fun getMuteListNote() = cache.getOrCreateAddressableNote(getMuteListAddress())
fun getMuteListFlow(): StateFlow<NoteState> = getMuteListNote().flow().metadata.stateFlow
fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent
suspend fun muteListWithBackup(note: Note): PeopleListEvent.UsersAndWords {
return muteList(
note.event as? MuteListEvent ?: settings.backupMuteList,
)
}
suspend fun muteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords {
return tryAndWait { continuation ->
event?.publicAndPrivateUsersAndWords(signer) {
continuation.resume(it)
}
} ?: PeopleListEvent.UsersAndWords()
}
val flow =
getMuteListFlow()
.map { muteListWithBackup(it.note) }
.onStart { emit(muteListWithBackup(getMuteListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
PeopleListEvent.UsersAndWords(),
)
fun hideUser(
pubkeyHex: String,
onDone: (MuteListEvent) -> Unit,
) {
val muteList = getMuteList()
if (muteList != null) {
MuteListEvent.addUser(
earlierVersion = muteList,
pubKeyHex = pubkeyHex,
isPrivate = true,
signer = signer,
onReady = onDone,
)
} else {
MuteListEvent.createListWithUser(
pubKeyHex = pubkeyHex,
isPrivate = true,
signer = signer,
onReady = onDone,
)
}
}
fun showUser(
pubkeyHex: String,
onDone: (MuteListEvent) -> Unit,
) {
val muteList = getMuteList()
if (muteList != null) {
MuteListEvent.removeUser(
earlierVersion = muteList,
pubKeyHex = pubkeyHex,
signer = signer,
onReady = onDone,
)
}
}
fun hideWord(
word: String,
onDone: (MuteListEvent) -> Unit,
) {
val muteList = getMuteList()
if (muteList != null) {
MuteListEvent.addWord(
earlierVersion = muteList,
word = word,
isPrivate = true,
signer = signer,
onReady = onDone,
)
} else {
MuteListEvent.createListWithWord(
word = word,
isPrivate = true,
signer = signer,
onReady = onDone,
)
}
}
fun showWord(
word: String,
onDone: (MuteListEvent) -> Unit,
) {
val muteList = getMuteList()
if (muteList != null) {
MuteListEvent.removeWord(
earlierVersion = muteList,
word = word,
signer = signer,
onReady = onDone,
)
}
}
init {
settings.backupMuteList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Mute List Collector Start")
getMuteListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Mute List for ${signer.pubKey}")
(it.note.event as? MuteListEvent)?.let {
settings.updateMuteList(it)
}
}
}
}
}
@@ -0,0 +1,71 @@
/**
* 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.model.nip56Reports
import android.R.attr.type
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
class ReportAction {
companion object {
fun report(
user: User,
type: ReportType,
by: User,
signer: NostrSigner,
onDone: (ReportEvent) -> Unit,
) {
if (!signer.isWriteable()) return
if (user.hasReport(by, type)) {
// has already reported this note
return
}
val template = ReportEvent.build(user.pubkeyHex, type)
signer.sign(template, onDone)
}
suspend fun report(
note: Note,
type: ReportType,
content: String = "",
by: User,
signer: NostrSigner,
onDone: (ReportEvent) -> Unit,
) {
if (!signer.isWriteable()) return
if (note.hasReport(by, type)) {
// has already reported this note
return
}
note.event?.let {
signer.sign(ReportEvent.build(it, type), onDone)
}
}
}
}
@@ -0,0 +1,149 @@
/**
* 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.model.nip65RelayList
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
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: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey)
fun getNIP65RelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress())
fun getNIP65RelayListFlow(): StateFlow<NoteState> = getNIP65RelayListNote().flow().metadata.stateFlow
fun getNIP65RelayList(): AdvertisedRelayListEvent? = getNIP65RelayListNote().event as? AdvertisedRelayListEvent
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
return event?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays
}
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
return event?.readRelaysNorm()?.toSet() ?: Constants.eventFinderRelays
}
fun normalizeNIP65AllRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
return event?.relays()?.map { it.relayUrl }?.toSet() ?: Constants.eventFinderRelays
}
val outboxFlow =
getNIP65RelayListFlow()
.map { normalizeNIP65WriteRelayListWithBackup(it.note) }
.onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
val inboxFlow =
getNIP65RelayListFlow()
.map { normalizeNIP65ReadRelayListWithBackup(it.note) }
.onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
val allFlow =
getNIP65RelayListFlow()
.map { normalizeNIP65AllRelayListWithBackup(it.note) }
.onStart { emit(normalizeNIP65AllRelayListWithBackup(getNIP65RelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
fun saveRelayList(
relays: List<AdvertisedRelayInfo>,
onDone: (AdvertisedRelayListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val nip65RelayList = getNIP65RelayList()
if (nip65RelayList != null) {
AdvertisedRelayListEvent.replaceRelayListWith(
earlierVersion = nip65RelayList,
newRelays = relays,
signer = signer,
onReady = onDone,
)
} else {
AdvertisedRelayListEvent.createFromScratch(
relays = relays,
signer = signer,
onReady = onDone,
)
}
}
init {
settings.backupNIP65RelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.Default) {
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)
}
}
}
}
}
@@ -0,0 +1,103 @@
/**
* 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.model.nip65RelayList
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
class OutboxRelaySetState(
usersToLoad: MutableStateFlow<Set<HexKey>>,
val cache: LocalCache,
scope: CoroutineScope,
) {
fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.Companion.createAddress(pubkey)
fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey))
fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
fun combineAllFlows(flows: List<StateFlow<NoteState>>): Flow<Set<NormalizedRelayUrl>> =
combine(flows) { relayListNotes: Array<NoteState> ->
relayListNotes.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()
}
}.map {
it.flatten().toSet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<NormalizedRelayUrl>> =
usersToLoad.transformLatest { followList ->
val flows: List<StateFlow<NoteState>> = allRelayListFlows(followList)
val relayListFlows = combineAllFlows(flows)
emitAll(relayListFlows)
}.onStart {
emit(
usersToLoad.value.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()
}.flatten().toSet(),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Companion.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowSet: StateFlow<Set<String>> =
flow.map { relayList ->
relayList.map { it.url }.toSet()
}.onStart {
emit(
usersToLoad.value.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()?.map { it.url }?.toSet()
}.flatten().toSet(),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Companion.Eagerly,
emptySet(),
)
}
@@ -0,0 +1,177 @@
/**
* 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.model.nip72Communities
import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
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.flow.transformLatest
import kotlinx.coroutines.launch
import kotlin.coroutines.resume
class CommunityListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getCommunityListAddress() = CommunityListEvent.createAddress(signer.pubKey)
fun getCommunityListNote(): AddressableNote = cache.getOrCreateAddressableNote(getCommunityListAddress())
fun getCommunityListFlow(): StateFlow<NoteState> = getCommunityListNote().flow().metadata.stateFlow
fun getCommunityList(): CommunityListEvent? = getCommunityListNote().event as? CommunityListEvent
suspend fun communityListWithBackup(note: Note): Set<AddressHint> {
return communityList(
note.event as? CommunityListEvent ?: settings.backupCommunityList,
)
}
suspend fun communityList(event: CommunityListEvent?): Set<AddressHint> {
return tryAndWait { continuation ->
event?.publicAndPrivateCommunities(signer) {
continuation.resume(it)
}
} ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<AddressHint>> by lazy {
getCommunityListFlow()
.transformLatest { noteState ->
emit(communityListWithBackup(noteState.note))
}.onStart {
emit(communityListWithBackup(getCommunityListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@OptIn(ExperimentalCoroutinesApi::class)
val flowSet: StateFlow<Set<String>> by lazy {
flow
.map {
it.mapTo(mutableSetOf()) { it.addressId }
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
fun follow(
communities: List<AddressableNote>,
onDone: (CommunityListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val communityList = getCommunityList()
val partialHint = communities.mapNotNull { it.toEventHint<CommunityDefinitionEvent>() }
if (communityList == null) {
CommunityListEvent.createCommunities(partialHint, true, signer, onReady = onDone)
} else {
CommunityListEvent.addCommunities(communityList, partialHint, true, signer, onReady = onDone)
}
}
fun follow(
community: AddressableNote,
onDone: (CommunityListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val communityList = getCommunityList()
val fullHint = community.toEventHint<CommunityDefinitionEvent>()
if (fullHint != null) {
if (communityList == null) {
CommunityListEvent.createCommunity(fullHint, true, signer, onReady = onDone)
} else {
CommunityListEvent.addCommunity(communityList, fullHint, true, signer, onReady = onDone)
}
} else {
val partialHint = community.toATag()
if (communityList == null) {
CommunityListEvent.createCommunity(partialHint, true, signer, onReady = onDone)
} else {
CommunityListEvent.addCommunity(communityList, partialHint, true, signer, onReady = onDone)
}
}
}
fun unfollow(
community: AddressableNote,
onDone: (CommunityListEvent) -> Unit,
) {
if (!signer.isWriteable()) return
val communityList = getCommunityList()
if (communityList != null) {
CommunityListEvent.removeCommunity(communityList, community.address.toValue(), signer, onReady = onDone)
}
}
init {
settings.backupCommunityList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
event.privateTags(signer) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Community List Collector Start")
getCommunityListFlow().collect {
Log.d("AccountRegisterObservers", "Community List for ${signer.pubKey}")
(it.note.event as? CommunityListEvent)?.let {
settings.updateCommunityListTo(it)
}
}
}
}
}
@@ -0,0 +1,111 @@
/**
* 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.model.nip78AppSpecific
import android.util.Log
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class AppSpecificState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
companion object {
const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings"
}
fun getAppSpecificDataAddress() = AppSpecificDataEvent.createAddress(signer.pubKey, APP_SPECIFIC_DATA_D_TAG)
fun getAppSpecificDataNote() = cache.getOrCreateAddressableNote(getAppSpecificDataAddress())
fun getAppSpecificDataFlow(): StateFlow<NoteState> = getAppSpecificDataNote().flow().metadata.stateFlow
fun saveNewAppSpecificData(onDone: (AppSpecificDataEvent) -> Unit) {
val toInternal = settings.syncedSettings.toInternal()
signer.nip44Encrypt(EventMapper.mapper.writeValueAsString(toInternal), signer.pubKey) { encrypted ->
AppSpecificDataEvent.create(
dTag = APP_SPECIFIC_DATA_D_TAG,
description = encrypted,
otherTags = emptyArray(),
signer = signer,
onReady = onDone,
)
}
}
init {
settings.backupAppSpecificData?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
signer.decrypt(event.content, event.pubKey) { decrypted ->
try {
val syncedSettings = EventMapper.mapper.readValue<AccountSyncedSettingsInternal>(decrypted)
settings.syncedSettings.updateFrom(syncedSettings)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e)
e.printStackTrace()
AccountSyncedSettingsInternal()
}
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "AppSpecificData Collector Start")
getAppSpecificDataFlow().collect {
Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}")
(it.note.event as? AppSpecificDataEvent)?.let {
signer.decrypt(it.content, it.pubKey) { decrypted ->
val syncedSettings =
try {
EventMapper.mapper.readValue<AccountSyncedSettingsInternal>(decrypted)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e)
e.printStackTrace()
AccountSyncedSettingsInternal()
}
settings.updateAppSpecificData(it, syncedSettings)
}
}
}
}
}
}
@@ -0,0 +1,84 @@
/**
* 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.model.nip96FileStorage
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import kotlinx.coroutines.CoroutineScope
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
class FileStorageServerListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getFileServersAddress() = FileServersEvent.createAddress(signer.pubKey)
fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getFileServersAddress())
fun getFileServersListFlow(): StateFlow<NoteState> = getFileServersNote().flow().metadata.stateFlow
fun getFileServersList(): FileServersEvent? = getFileServersNote().event as? FileServersEvent
fun normalizeServers(note: Note): List<String> {
val event = note.event as? FileServersEvent
return event?.servers() ?: emptyList()
}
val fileServers =
getFileServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(getFileServersNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
fun saveFileServersList(
servers: List<String>,
onDone: (FileServersEvent) -> Unit,
) {
val serverList = getFileServersList()
val template =
if (serverList != null && serverList.tags.isNotEmpty()) {
FileServersEvent.replaceServers(serverList, servers)
} else {
FileServersEvent.build(servers)
}
signer.sign(template, onDone)
}
}
@@ -0,0 +1,91 @@
/**
* 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.model.nipB7Blossom
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import kotlinx.coroutines.CoroutineScope
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
class BlossomServerListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getBlossomServersAddress() = BlossomServersEvent.createAddress(signer.pubKey)
fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getBlossomServersAddress())
fun getBlossomServersListFlow(): StateFlow<NoteState> = getBlossomServersNote().flow().metadata.stateFlow
fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent
fun normalizeServers(note: Note): List<String> {
val event = note.event as? FileServersEvent
return event?.servers() ?: emptyList()
}
val fileServers =
getBlossomServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(getBlossomServersNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
fun saveBlossomServersList(
servers: List<String>,
onDone: (BlossomServersEvent) -> Unit,
) {
val serverList = getBlossomServersList()
if (serverList != null && serverList.tags.isNotEmpty()) {
BlossomServersEvent.updateRelayList(
earlierVersion = serverList,
relays = servers,
signer = signer,
onReady = onDone,
)
} else {
BlossomServersEvent.createFromScratch(
relays = servers,
signer = signer,
onReady = onDone,
)
}
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -51,10 +52,8 @@ class LatestByKindAndAuthor<T : Event>(
if ((kind in 10000..19999) || (kind in 30000..39999)) {
LocalCache.addressables
.maxOrNullOf(
filter = { idHex: String, note: AddressableNote ->
note.event?.let {
it.kind == kind && it.pubKey == pubkey
} == true
filter = { address: Address, note: AddressableNote ->
address.kind == kind && address.pubKeyHex == pubkey
},
comparator = CreatedAtComparatorAddresses,
)?.event as? T
@@ -0,0 +1,84 @@
/**
* 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.model.serverList
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState
import com.vitorpamplona.amethyst.model.nip51Lists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.HashtagListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class MergedFollowListsState(
val kind3List: FollowListState,
val hashtagList: HashtagListState,
val geohashList: GeohashListState,
val communityList: CommunityListState,
val scope: CoroutineScope,
) {
fun mergeLists(
kind3: FollowListState.Kind3Follows,
hashtages: Set<String>,
geohashes: Set<String>,
community: Set<AddressHint>,
): FollowListState.Kind3Follows {
return FollowListState.Kind3Follows(
kind3.authors,
kind3.authorsPlusMe,
kind3.hashtags + hashtages,
kind3.geotags + geohashes,
kind3.communities + community.map { it.addressId },
)
}
val flow: StateFlow<FollowListState.Kind3Follows> =
combine(
kind3List.flow,
hashtagList.flow,
geohashList.flow,
communityList.flow,
) { kind3, hashtag, geohash, community ->
mergeLists(kind3, hashtag, geohash, community)
}
.onStart {
emit(
mergeLists(
kind3List.flow.value,
hashtagList.flow.value,
geohashList.flow.value,
communityList.flow.value,
),
)
}
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
kind3List.flow.value,
)
}
@@ -0,0 +1,86 @@
/**
* 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.model.serverList
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxRelays
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class MergedFollowPlusMineRelayListsState(
val followsOutboxRelayList: FollowListOutboxRelays,
val nip65RelayList: Nip65RelayListState,
val privateOutboxRelayList: PrivateStorageRelayListState,
val localRelayList: LocalRelayListState,
val scope: CoroutineScope,
) {
fun mergeLists(
kind3: Set<NormalizedRelayUrl>,
outbox: Set<NormalizedRelayUrl>,
inbox: Set<NormalizedRelayUrl>,
private: Set<NormalizedRelayUrl>,
local: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> {
return kind3 + outbox + inbox + private + local
}
val flow: StateFlow<Set<NormalizedRelayUrl>> =
combine(
followsOutboxRelayList.flow,
nip65RelayList.outboxFlow,
nip65RelayList.inboxFlow,
privateOutboxRelayList.flow,
localRelayList.flow,
::mergeLists,
)
.onStart {
emit(
mergeLists(
followsOutboxRelayList.flow.value,
nip65RelayList.outboxFlow.value,
nip65RelayList.inboxFlow.value,
privateOutboxRelayList.flow.value,
localRelayList.flow.value,
),
)
}
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
mergeLists(
followsOutboxRelayList.flow.value,
nip65RelayList.outboxFlow.value,
nip65RelayList.inboxFlow.value,
privateOutboxRelayList.flow.value,
localRelayList.flow.value,
),
)
}
@@ -0,0 +1,74 @@
/**
* 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.model.serverList
import android.R.attr.host
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import org.czeal.rfc3986.URIReference
class MergedServerListState(
val fileServers: StateFlow<List<String>>,
val blossomServers: StateFlow<List<String>>,
val scope: CoroutineScope,
) {
fun host(url: String): String =
try {
URIReference.parse(url).host.value
} catch (e: Exception) {
url
}
fun mergeServerList(
nip96: List<String>?,
blossom: List<String>?,
): List<ServerName> {
val nip96servers = nip96?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList()
val blossomServers = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList()
val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS }
return result + ServerName("NIP95", "", ServerType.NIP95)
}
val liveServerList: StateFlow<List<ServerName>> by lazy {
combine(fileServers, blossomServers) { nip96s, blossoms ->
mergeServerList(nip96s, blossoms)
}
.onStart { emit(mergeServerList(fileServers.value, blossomServers.value)) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
}
}
@@ -0,0 +1,88 @@
/**
* 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.model.serverList
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip50Search.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class TrustedRelayListsState(
val nip65RelayList: Nip65RelayListState,
val privateOutboxRelayList: PrivateStorageRelayListState,
val localRelayList: LocalRelayListState,
val dmRelayList: DmRelayListState,
val searchRelayListState: SearchRelayListState,
val scope: CoroutineScope,
) {
fun mergeLists(
nip65: Set<NormalizedRelayUrl>,
private: Set<NormalizedRelayUrl>,
local: Set<NormalizedRelayUrl>,
dm: Set<NormalizedRelayUrl>,
search: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> {
return nip65 + private + local + dm + search
}
val flow: StateFlow<Set<NormalizedRelayUrl>> =
combine(
nip65RelayList.allFlow,
privateOutboxRelayList.flow,
localRelayList.flow,
dmRelayList.flow,
searchRelayListState.flow,
::mergeLists,
)
.onStart {
emit(
mergeLists(
nip65RelayList.allFlow.value,
privateOutboxRelayList.flow.value,
localRelayList.flow.value,
dmRelayList.flow.value,
searchRelayListState.flow.value,
),
)
}
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
mergeLists(
nip65RelayList.allFlow.value,
privateOutboxRelayList.flow.value,
localRelayList.flow.value,
dmRelayList.flow.value,
searchRelayListState.flow.value,
),
)
}
@@ -0,0 +1,81 @@
/**
* 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.model.topNavFeeds
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlin.collections.forEach
import kotlin.collections.ifEmpty
class CommunityRelayLoader {
companion object {
fun communitiesPerRelay(
communityNotes: Array<NoteState>,
cache: LocalCache,
): Map<NormalizedRelayUrl, Set<HexKey>> {
return mapOfSet {
communityNotes.forEach { communityNote ->
val relays =
(communityNote.note.event as? CommunityDefinitionEvent)?.relayUrls()
?.ifEmpty { null }
?: cache.relayHints.hintsForAddress(communityNote.note.idHex)
relays.forEach {
add(it, communityNote.note.idHex)
}
}
}
}
fun <T> communitiesPerRelaySnapshot(
communities: Set<HexKey>,
cache: LocalCache,
transformation: (Map<NormalizedRelayUrl, Set<HexKey>>) -> T,
): T {
val noteMetadata =
communities.mapNotNull { addressId ->
cache.checkGetOrCreateAddressableNote(addressId)?.flow()?.metadata?.stateFlow?.value
}.toTypedArray()
return transformation(communitiesPerRelay(noteMetadata, cache))
}
fun <T> toCommunitiesPerRelayFlow(
communities: Set<HexKey>,
cache: LocalCache,
transformation: (Map<NormalizedRelayUrl, Set<HexKey>>) -> T,
): Flow<T> {
val noteMetadataFlows =
communities.mapNotNull { addressId ->
cache.checkGetOrCreateAddressableNote(addressId)?.flow()?.metadata?.stateFlow
}
return combine(noteMetadataFlows) { communityNotes ->
transformation(communitiesPerRelay(communityNotes, cache))
}
}
}
}
@@ -0,0 +1,86 @@
/**
* 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.model.topNavFeeds
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
class FeedTopNavFilterState(
val feedFilterListName: MutableStateFlow<String>,
val allFollows: StateFlow<FollowListState.Kind3Follows>,
val locationFlow: StateFlow<LocationState.LocationResult>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val signer: NostrSigner,
val scope: CoroutineScope,
) {
fun loadFlowsFor(listName: String): IFeedFlowsType =
when (listName) {
GLOBAL_FOLLOWS -> GlobalFeedFlow(followsRelays)
ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays)
AROUND_ME -> AroundMeFeedFlow(locationFlow, followsRelays)
else -> {
val note = LocalCache.checkGetOrCreateAddressableNote(listName)
if (note != null) {
NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays)
} else {
UnknownFeedFlow(listName)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<IFeedTopNavFilter> =
feedFilterListName.transformLatest { listName ->
emitAll(loadFlowsFor(listName).flow())
}
.onStart {
loadFlowsFor(feedFilterListName.value).startValue(this)
}
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
AuthorsByOutboxTopNavFilter(emptySet()),
)
}
@@ -18,16 +18,13 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relays
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.ammolite.relays.NostrClient
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
sealed class RelayServiceStatus {
data class Active(
val client: NostrClient,
) : RelayServiceStatus()
interface IFeedFlowsType {
fun flow(): Flow<IFeedTopNavFilter>
object Off : RelayServiceStatus()
object Connecting : RelayServiceStatus()
suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>)
}
@@ -18,23 +18,19 @@
* 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.ammolite.relays.filters
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
interface IPerRelayFilter {
fun toRelay(forRelay: String): Filter
interface IFeedTopNavFilter {
fun matchAuthor(pubkey: HexKey): Boolean
fun toJson(forRelay: String): String
fun match(noteEvent: Event): Boolean
fun match(
event: Event,
forRelay: String,
): Boolean
fun toPerRelayFlow(cache: LocalCache): Flow<IFeedTopNavPerRelayFilterSet>
fun toDebugJson(): String
// This only exists because some relays confuse empty lists with null lists
fun isValidFor(url: String): Boolean
fun startValue(cache: LocalCache): IFeedTopNavPerRelayFilterSet
}
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
interface IFeedTopNavPerRelayFilter
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
interface IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,112 @@
/**
* 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.model.topNavFeeds
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class MergedTopFeedAuthorListsState(
val homeNavFilter: StateFlow<IFeedTopNavPerRelayFilterSet>,
val videoNavFilter: StateFlow<IFeedTopNavPerRelayFilterSet>,
val discoveryNavFilter: StateFlow<IFeedTopNavPerRelayFilterSet>,
val notificationNavFilter: StateFlow<IFeedTopNavPerRelayFilterSet>,
val scope: CoroutineScope,
) {
fun authorList(navFilter: IFeedTopNavPerRelayFilterSet): Map<NormalizedRelayUrl, Set<HexKey>?> {
return when (navFilter) {
is AllCommunitiesTopNavPerRelayFilterSet -> emptyMap()
is AllFollowsByOutboxTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors }
is AuthorsByOutboxTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors }
is GlobalTopNavPerRelayFilterSet -> emptyMap()
is HashtagTopNavPerRelayFilterSet -> emptyMap()
is LocationTopNavPerRelayFilterSet -> emptyMap()
is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors }
is SingleCommunityTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors }
else -> emptyMap()
}
}
fun mergeLists(
homeNavFilter: IFeedTopNavPerRelayFilterSet,
videoNavFilter: IFeedTopNavPerRelayFilterSet,
discoveryNavFilter: IFeedTopNavPerRelayFilterSet,
notificationNavFilter: IFeedTopNavPerRelayFilterSet,
): Map<NormalizedRelayUrl, Set<HexKey>> {
return mapOfSet {
authorList(homeNavFilter).forEach { (relay, authors) ->
authors?.let { add(relay, authors) }
}
authorList(videoNavFilter).forEach { (relay, authors) ->
authors?.let { add(relay, authors) }
}
authorList(discoveryNavFilter).forEach { (relay, authors) ->
authors?.let { add(relay, authors) }
}
authorList(notificationNavFilter).forEach { (relay, authors) ->
authors?.let { add(relay, authors) }
}
}
}
val flow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
combine(
homeNavFilter,
videoNavFilter,
discoveryNavFilter,
notificationNavFilter,
::mergeLists,
).onStart {
emit(
mergeLists(
homeNavFilter.value,
videoNavFilter.value,
discoveryNavFilter.value,
notificationNavFilter.value,
),
)
}
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyMap(),
)
}
@@ -0,0 +1,53 @@
/**
* 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.model.topNavFeeds
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
class OutboxLoaderState(
topNavFilter: StateFlow<IFeedTopNavFilter>,
cache: LocalCache,
scope: CoroutineScope,
) {
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<IFeedTopNavPerRelayFilterSet> =
topNavFilter.transformLatest { filterSettings ->
emitAll(filterSettings.toPerRelayFlow(cache))
}.onStart {
emit(topNavFilter.value.startValue(cache))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Companion.Eagerly,
UnknownTopNavPerRelayFilterSet,
)
}
@@ -0,0 +1,80 @@
/**
* 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.model.topNavFeeds
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlin.collections.ifEmpty
class OutboxRelayLoader {
companion object {
private fun authorsPerRelay(
outboxRelayNotes: Array<NoteState>,
cache: LocalCache,
): Map<NormalizedRelayUrl, Set<HexKey>> {
return mapOfSet {
outboxRelayNotes.forEach { outboxNote ->
val relays =
(outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.ifEmpty { null }
?: outboxNote.note.author?.pubkeyHex ?.let { cache.relayHints.hintsForKey(it) }
relays?.forEach {
add(it, outboxNote.note.idHex)
}
}
}
}
fun <T> authorsPerRelaySnapshot(
authors: Set<HexKey>,
cache: LocalCache,
transformation: (Map<NormalizedRelayUrl, Set<HexKey>>) -> T,
): T {
val noteMetadata =
authors.map { pubkeyHex ->
cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)).flow().metadata.stateFlow.value
}.toTypedArray()
return transformation(authorsPerRelay(noteMetadata, cache))
}
fun <T> toAuthorsPerRelayFlow(
authors: Set<HexKey>,
cache: LocalCache,
transformation: (Map<NormalizedRelayUrl, Set<HexKey>>) -> T,
): Flow<T> {
val noteMetadataFlows =
authors.map { pubkeyHex ->
val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex))
note.flow().metadata.stateFlow
}
return combine(noteMetadataFlows) { outboxRelays ->
transformation(authorsPerRelay(outboxRelays, cache))
}
}
}
}
@@ -0,0 +1,140 @@
/**
* 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.model.topNavFeeds.allFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes
import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
/**
* This is a big OR filter on all fields.
*/
@Immutable
class AllFollowsByOutboxTopNavFilter(
val authors: Set<String>? = null,
val hashtags: Set<String>? = null,
val geotags: Set<String>? = null,
val communities: Set<String>? = null,
val defaultRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedTopNavFilter {
val geotagScopes: Set<String>? = geotags?.mapTo(mutableSetOf<String>()) { GeohashId.Companion.toScope(it) }
val hashtagScopes: Set<String>? = hashtags?.mapTo(mutableSetOf<String>()) { HashtagId.Companion.toScope(it) }
override fun matchAuthor(pubkey: HexKey): Boolean {
return authors == null || pubkey in authors
}
override fun match(noteEvent: Event): Boolean {
return if (noteEvent is LiveActivitiesEvent) {
(authors != null && noteEvent.participantsIntersect(authors)) ||
(hashtags != null && noteEvent.isTaggedHashes(hashtags)) ||
(geotags != null && noteEvent.isTaggedGeoHashes(geotags)) ||
(communities != null && noteEvent.isTaggedAddressableNotes(communities))
} else if (noteEvent is CommentEvent) {
// ignore follows and checks only the root scope
(authors != null && noteEvent.pubKey in authors) ||
(hashtags != null && noteEvent.isTaggedHashes(hashtags)) ||
(hashtagScopes != null && noteEvent.isTaggedScopes(hashtagScopes)) ||
(geotags != null && noteEvent.isTaggedGeoHashes(geotags)) ||
(geotagScopes != null && noteEvent.isTaggedScopes(geotagScopes)) ||
(communities != null && noteEvent.isTaggedAddressableNotes(communities))
} else {
(authors != null && noteEvent.pubKey in authors) ||
(hashtags != null && noteEvent.isTaggedHashes(hashtags)) ||
(geotags != null && noteEvent.isTaggedGeoHashes(geotags)) ||
(communities != null && noteEvent.isTaggedAddressableNotes(communities))
}
}
override fun toPerRelayFlow(cache: LocalCache): Flow<AllFollowsByOutboxTopNavPerRelayFilterSet> {
val authorsPerRelay =
if (authors != null) {
OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it }
} else {
MutableStateFlow(emptyMap())
}
val communitiesPerRelay =
if (communities != null) {
CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it }
} else {
MutableStateFlow(emptyMap())
}
return combine(authorsPerRelay, communitiesPerRelay, defaultRelays) { perRelayAuthors, perRelayCommunities, default ->
val allRelays = (perRelayAuthors.keys + perRelayCommunities.keys).ifEmpty { default }
AllFollowsByOutboxTopNavPerRelayFilterSet(
allRelays.associateWith {
AllFollowsByOutboxTopNavPerRelayFilter(
authors = perRelayAuthors[it],
hashtags = hashtags,
geotags = geotags,
communities = perRelayCommunities[it],
)
},
)
}
}
override fun startValue(cache: LocalCache): AllFollowsByOutboxTopNavPerRelayFilterSet {
val authorsPerRelay =
if (authors != null) {
OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it }
} else {
emptyMap()
}
val communitiesPerRelay =
if (communities != null) {
CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it }
} else {
emptyMap()
}
val allRelays = (authorsPerRelay.keys + communitiesPerRelay.keys).ifEmpty { defaultRelays.value }
return AllFollowsByOutboxTopNavPerRelayFilterSet(
allRelays.associateWith {
AllFollowsByOutboxTopNavPerRelayFilter(
authors = authorsPerRelay[it],
hashtags = hashtags,
geotags = geotags,
communities = communitiesPerRelay[it],
)
},
)
}
}
@@ -18,21 +18,23 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3
package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.quartz.nip01Core.relay.RelayStat
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
/**
* This is a big OR filter.
*/
@Immutable
data class Kind3BasicRelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
class AllFollowsByOutboxTopNavPerRelayFilter(
val authors: Set<String>? = null,
val hashtags: Set<String>? = null,
val geotags: Set<String>? = null,
val communities: Set<String>? = null,
) : IFeedTopNavPerRelayFilter {
val geotagScopes: Set<String>? = geotags?.mapTo(mutableSetOf<String>()) { GeohashId.toScope(it) }
val hashtagScopes: Set<String>? = hashtags?.mapTo(mutableSetOf<String>()) { HashtagId.toScope(it) }
}
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.allFollows
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class AllFollowsByOutboxTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AllFollowsByOutboxTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,57 @@
/**
* 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.model.topNavFeeds.allFollows
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class AllFollowsFeedFlow(
val allFollows: StateFlow<FollowListState.Kind3Follows?>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
fun convert(kind3: FollowListState.Kind3Follows?): AllFollowsByOutboxTopNavFilter {
return if (kind3 != null) {
AllFollowsByOutboxTopNavFilter(
authors = kind3.authors,
hashtags = kind3.hashtags,
geotags = kind3.geotags,
communities = kind3.communities,
defaultRelays = followsRelays,
)
} else {
AllFollowsByOutboxTopNavFilter(
authors = emptySet(),
defaultRelays = followsRelays,
)
}
}
override fun flow() = allFollows.map(::convert)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(convert(allFollows.value))
}
}
@@ -0,0 +1,63 @@
/**
* 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.model.topNavFeeds.aroundMe
import com.fonfon.kgeohash.GeoHash
fun compute50kmLine(geoHash: GeoHash): List<String> {
val hashes = mutableListOf<String>()
hashes.add(geoHash.toString())
var currentGeoHash = geoHash
repeat(5) {
currentGeoHash = currentGeoHash.westernNeighbour
hashes.add(currentGeoHash.toString())
}
currentGeoHash = geoHash
repeat(5) {
currentGeoHash = currentGeoHash.easternNeighbour
hashes.add(currentGeoHash.toString())
}
return hashes
}
fun compute50kmRange(geoHash: GeoHash): List<String> {
val hashes = mutableListOf<String>()
hashes.addAll(compute50kmLine(geoHash))
var currentGeoHash = geoHash
repeat(5) {
currentGeoHash = currentGeoHash.northernNeighbour
hashes.addAll(compute50kmLine(currentGeoHash))
}
currentGeoHash = geoHash
repeat(5) {
currentGeoHash = currentGeoHash.southernNeighbour
hashes.addAll(compute50kmLine(currentGeoHash))
}
return hashes
}
@@ -0,0 +1,56 @@
/**
* 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.model.topNavFeeds.aroundMe
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class AroundMeFeedFlow(
val location: StateFlow<LocationState.LocationResult>,
val allFollowRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
fun convert(result: LocationState.LocationResult): LocationTopNavFilter {
return if (result is LocationState.LocationResult.Success) {
// 2 neighbors deep = 25x25km
LocationTopNavFilter(
geotags = compute50kmRange(result.geoHash).toSet(),
relays = allFollowRelays,
)
} else {
// empty feed until we have a successful geohash
LocationTopNavFilter(
geotags = emptySet(),
relays = allFollowRelays,
)
}
}
override fun flow() = location.map(::convert)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(convert(location.value))
}
}
@@ -0,0 +1,65 @@
/**
* 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.model.topNavFeeds.aroundMe
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@Immutable
class LocationTopNavFilter(
val geotags: Set<String>,
val relays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedTopNavFilter {
val geotagScopes: Set<String> = geotags.mapTo(mutableSetOf<String>()) { GeohashId.Companion.toScope(it) }
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event): Boolean {
if (geotags.isEmpty()) return false
return if (noteEvent is CommentEvent) {
noteEvent.isTaggedGeoHashes(geotags) ||
noteEvent.isTaggedScopes(geotagScopes)
} else {
noteEvent.isTaggedGeoHashes(geotags)
}
}
override fun toPerRelayFlow(cache: LocalCache): Flow<LocationTopNavPerRelayFilterSet> {
return relays.map {
LocationTopNavPerRelayFilterSet(it.associateWith { LocationTopNavPerRelayFilter(geotags) })
}
}
override fun startValue(cache: LocalCache): LocationTopNavPerRelayFilterSet {
return LocationTopNavPerRelayFilterSet(relays.value.associateWith { LocationTopNavPerRelayFilter(geotags) })
}
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
@Immutable
class LocationTopNavPerRelayFilter(
val geotags: Set<String>,
) : IFeedTopNavPerRelayFilter {
val geotagScopes: Set<String> = geotags.mapTo(mutableSetOf<String>()) { GeohashId.toScope(it) }
}
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.aroundMe
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class LocationTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, LocationTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -18,29 +18,23 @@
* 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.ammolite.relays
package com.vitorpamplona.amethyst.model.topNavFeeds.global
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object RelayBriefInfoCache {
val cache = LruCache<String, RelayBriefInfo?>(50)
class GlobalFeedFlow(
val relays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
val default = GlobalTopNavFilter(relays)
@Immutable
class RelayBriefInfo(
val url: String,
) {
val displayUrl: String = RelayUrlFormatter.displayUrl(url).intern()
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
}
override fun flow() = MutableStateFlow(default)
fun get(url: String): RelayBriefInfo {
val info = cache[url]
if (info != null) return info
val newInfo = RelayBriefInfo(url)
cache.put(url, newInfo)
return newInfo
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(default)
}
}
@@ -0,0 +1,52 @@
/**
* 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.model.topNavFeeds.global
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@Immutable
class GlobalTopNavFilter(
val relays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event) = true
override fun toPerRelayFlow(cache: LocalCache): Flow<GlobalTopNavPerRelayFilterSet> {
return relays.map {
GlobalTopNavPerRelayFilterSet(it.associateWith { GlobalTopNavPerRelayFilter })
}
}
override fun startValue(cache: LocalCache): GlobalTopNavPerRelayFilterSet {
return GlobalTopNavPerRelayFilterSet(
relays.value.associateWith { GlobalTopNavPerRelayFilter },
)
}
}
@@ -18,14 +18,10 @@
* 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.ammolite.relays
package com.vitorpamplona.amethyst.model.topNavFeeds.global
import com.vitorpamplona.ammolite.relays.filters.IPerRelayFilter
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
class TypedFilter(
val types: Set<FeedType>,
val filter: IPerRelayFilter,
) {
// This only exists because some relays confuse empty lists with null lists
fun isValidFor(url: String) = filter.isValidFor(url)
}
@Immutable
object GlobalTopNavPerRelayFilter : IFeedTopNavPerRelayFilter
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.global
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class GlobalTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, GlobalTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,68 @@
/**
* 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.model.topNavFeeds.hashtag
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@Immutable
class HashtagTopNavFilter(
val hashtags: Set<String>,
val relays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedTopNavFilter {
val hashtagScopes: Set<String> = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) }
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event): Boolean {
return if (noteEvent is CommentEvent) {
noteEvent.isTaggedHashes(hashtags) || noteEvent.isTaggedScopes(hashtagScopes)
} else {
noteEvent.isTaggedHashes(hashtags)
}
}
override fun toPerRelayFlow(cache: LocalCache): Flow<HashtagTopNavPerRelayFilterSet> {
return relays.map {
HashtagTopNavPerRelayFilterSet(
it.associateWith { HashtagTopNavPerRelayFilter(hashtags) },
)
}
}
override fun startValue(cache: LocalCache): HashtagTopNavPerRelayFilterSet {
return HashtagTopNavPerRelayFilterSet(
relays.value.associateWith {
HashtagTopNavPerRelayFilter(hashtags)
},
)
}
}
@@ -18,23 +18,15 @@
* 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.ammolite.relays
package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
@Immutable
data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
)
@Immutable
data class RelaySetupInfoToConnect(
val url: String,
val forceProxy: Boolean,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
)
class HashtagTopNavPerRelayFilter(
val hashtags: Set<String>,
) : IFeedTopNavPerRelayFilter {
val hashtagScopes: Set<String> = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) }
}
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.hashtag
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class HashtagTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, HashtagTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,131 @@
/**
* 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.model.topNavFeeds.noteBased
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.transformLatest
class NoteFeedFlow(
val metadataFlow: StateFlow<NoteState?>,
val signer: NostrSigner,
val allFollowRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
suspend fun FlowCollector<IFeedTopNavFilter>.process(noteEvent: Event) {
when (noteEvent) {
is PeopleListEvent -> {
if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) {
emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicUsersAndWords().users))
noteEvent.publicAndPrivateUsersAndWords(signer)?.let {
emit(MutedAuthorsByOutboxTopNavFilter(it.users))
}
} else {
emit(AuthorsByOutboxTopNavFilter(noteEvent.publicUsersAndWords().users))
noteEvent.publicAndPrivateUsersAndWords(signer)?.let {
emit(AuthorsByOutboxTopNavFilter(it.users))
}
}
}
is MuteListEvent -> {
emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicUsersAndWords().users))
noteEvent.publicAndPrivateUsersAndWords(signer)?.let {
emit(MutedAuthorsByOutboxTopNavFilter(it.users))
}
}
is FollowListEvent -> {
emit(AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet()))
}
is CommunityListEvent -> {
emit(AllCommunitiesTopNavFilter(noteEvent.publicCommunityIds().toSet()))
noteEvent.publicAndPrivateCommunities(signer)?.let {
val communities = it.map { it.addressId }.toSet()
emit(AllCommunitiesTopNavFilter(communities))
}
}
is HashtagListEvent -> {
emit(HashtagTopNavFilter(noteEvent.publicHashtags().toSet(), allFollowRelays))
noteEvent.publicAndPrivateHashtag(signer)?.let {
emit(HashtagTopNavFilter(it, allFollowRelays))
}
}
is GeohashListEvent -> {
emit(LocationTopNavFilter(noteEvent.publicGeohashes().toSet(), allFollowRelays))
noteEvent.publicAndPrivateGeohash(signer)?.let {
emit(LocationTopNavFilter(it, allFollowRelays))
}
}
is CommunityDefinitionEvent -> {
SingleCommunityTopNavFilter(
community = noteEvent.addressTag(),
authors = noteEvent.moderatorKeys().toSet().ifEmpty { null },
relays = noteEvent.relayUrls().toSet(),
)
}
else -> AuthorsByOutboxTopNavFilter(emptySet())
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun flow() =
metadataFlow.transformLatest { noteState ->
val noteEvent = noteState?.note?.event
if (noteEvent == null) {
AuthorsByOutboxTopNavFilter(emptySet())
} else {
process(noteEvent)
}
}
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
val noteEvent = metadataFlow.value?.note?.event
if (noteEvent == null) {
AuthorsByOutboxTopNavFilter(emptySet())
} else {
collector.process(noteEvent)
}
}
}
@@ -0,0 +1,59 @@
/**
* 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.model.topNavFeeds.noteBased.allcommunities
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes
import kotlinx.coroutines.flow.Flow
@Immutable
class AllCommunitiesTopNavFilter(
val communities: Set<String>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey): Boolean = true
override fun match(noteEvent: Event): Boolean {
return noteEvent.isTaggedAddressableNotes(communities)
}
fun convert(map: Map<NormalizedRelayUrl, Set<HexKey>>) =
AllCommunitiesTopNavPerRelayFilterSet(
map.mapValues { AllCommunitiesTopNavPerRelayFilter(it.value) },
)
override fun toPerRelayFlow(cache: LocalCache): Flow<AllCommunitiesTopNavPerRelayFilterSet> {
return CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) {
convert(it)
}
}
override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet {
return CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) {
convert(it)
}
}
}
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
class AllCommunitiesTopNavPerRelayFilter(
val communities: Set<String>,
) : IFeedTopNavPerRelayFilter
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.noteBased.allcommunities
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class AllCommunitiesTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AllCommunitiesTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,59 @@
/**
* 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.model.topNavFeeds.noteBased.author
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlinx.coroutines.flow.Flow
@Immutable
class AuthorsByOutboxTopNavFilter(
val authors: Set<String>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey) = pubkey in authors
override fun match(noteEvent: Event): Boolean {
return if (noteEvent is LiveActivitiesEvent) {
noteEvent.participantsIntersect(authors)
} else {
noteEvent.pubKey in authors
}
}
fun convert(map: Map<NormalizedRelayUrl, Set<HexKey>>) =
AuthorsByOutboxTopNavPerRelayFilterSet(
map.mapValues { AuthorsByOutboxTopNavPerRelayFilter(it.value) },
)
override fun toPerRelayFlow(cache: LocalCache): Flow<AuthorsByOutboxTopNavPerRelayFilterSet> {
return OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert)
}
override fun startValue(cache: LocalCache): AuthorsByOutboxTopNavPerRelayFilterSet {
return OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache, ::convert)
}
}
@@ -0,0 +1,29 @@
/**
* 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.model.topNavFeeds.noteBased.author
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
@Immutable
class AuthorsByOutboxTopNavPerRelayFilter(
val authors: Set<String>,
) : IFeedTopNavPerRelayFilter
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.noteBased.author
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class AuthorsByOutboxTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, AuthorsByOutboxTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,115 @@
/**
* 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.model.topNavFeeds.noteBased.community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@Immutable
class SingleCommunityTopNavFilter(
val community: String,
val authors: Set<String>?,
val relays: Set<NormalizedRelayUrl>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey) = authors == null || pubkey in authors
override fun match(noteEvent: Event): Boolean {
return if (noteEvent is LiveActivitiesEvent) {
(authors != null && noteEvent.participantsIntersect(authors)) || noteEvent.isTaggedAddressableNote(community)
} else if (noteEvent is CommentEvent) {
(authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community)
} else {
(authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community)
}
}
override fun toPerRelayFlow(cache: LocalCache): Flow<SingleCommunityTopNavPerRelayFilterSet> {
// relay field takes priority
if (relays.isNotEmpty()) {
return MutableStateFlow(
SingleCommunityTopNavPerRelayFilterSet(
relays.associateWith {
SingleCommunityTopNavPerRelayFilter(community, authors)
},
),
)
}
if (authors != null) {
// go by authors
return OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) {
SingleCommunityTopNavPerRelayFilterSet(
it.mapValues {
SingleCommunityTopNavPerRelayFilter(community, it.value)
},
)
}
}
// go by hints
return MutableStateFlow(
SingleCommunityTopNavPerRelayFilterSet(
cache.relayHints.hintsForAddress(community).associateWith {
SingleCommunityTopNavPerRelayFilter(community, authors)
},
),
)
}
override fun startValue(cache: LocalCache): SingleCommunityTopNavPerRelayFilterSet {
// relay field takes priority
if (relays.isNotEmpty()) {
return SingleCommunityTopNavPerRelayFilterSet(
relays.associateWith {
SingleCommunityTopNavPerRelayFilter(community, authors)
},
)
}
if (authors != null) {
// go by authors
return OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) {
SingleCommunityTopNavPerRelayFilterSet(
it.mapValues {
SingleCommunityTopNavPerRelayFilter(community, it.value)
},
)
}
}
// go by hints
return SingleCommunityTopNavPerRelayFilterSet(
cache.relayHints.hintsForAddress(community).associateWith {
SingleCommunityTopNavPerRelayFilter(community, authors)
},
)
}
}
@@ -0,0 +1,30 @@
/**
* 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.model.topNavFeeds.noteBased.community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
@Immutable
class SingleCommunityTopNavPerRelayFilter(
val community: String,
val authors: Set<String>?,
) : IFeedTopNavPerRelayFilter
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.noteBased.community
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class SingleCommunityTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, SingleCommunityTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,59 @@
/**
* 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.model.topNavFeeds.noteBased.muted
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlinx.coroutines.flow.Flow
@Immutable
class MutedAuthorsByOutboxTopNavFilter(
val authors: Set<String>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey) = pubkey in authors
override fun match(noteEvent: Event): Boolean {
return if (noteEvent is LiveActivitiesEvent) {
noteEvent.participantsIntersect(authors)
} else {
noteEvent.pubKey in authors
}
}
fun convert(map: Map<NormalizedRelayUrl, Set<HexKey>>) =
MutedAuthorsByOutboxTopNavPerRelayFilterSet(
map.mapValues { MutedAuthorsByOutboxTopNavPerRelayFilter(it.value) },
)
override fun toPerRelayFlow(cache: LocalCache): Flow<MutedAuthorsByOutboxTopNavPerRelayFilterSet> {
return OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert)
}
override fun startValue(cache: LocalCache): MutedAuthorsByOutboxTopNavPerRelayFilterSet {
return OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache, ::convert)
}
}
@@ -0,0 +1,29 @@
/**
* 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.model.topNavFeeds.noteBased.muted
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
@Immutable
class MutedAuthorsByOutboxTopNavPerRelayFilter(
val authors: Set<String>,
) : IFeedTopNavPerRelayFilter
@@ -0,0 +1,28 @@
/**
* 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.model.topNavFeeds.noteBased.muted
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class MutedAuthorsByOutboxTopNavPerRelayFilterSet(
val set: Map<NormalizedRelayUrl, MutedAuthorsByOutboxTopNavPerRelayFilter>,
) : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
class UnknownFeedFlow(
val feedName: String,
) : IFeedFlowsType {
override fun flow() = MutableStateFlow(UnknownTopNavFilter(feedName))
// empty feed
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(UnknownTopNavFilter(feedName))
}
}
@@ -18,23 +18,27 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
import androidx.compose.runtime.Immutable
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.RelayStat
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@Immutable
data class Kind3RelayProposalSetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
val users: List<HexKey>,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
class UnknownTopNavFilter(
val feedName: String,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey) = false
override fun match(noteEvent: Event) = false
override fun toPerRelayFlow(cache: LocalCache): Flow<UnknownTopNavPerRelayFilterSet> {
return MutableStateFlow(UnknownTopNavPerRelayFilterSet)
}
override fun startValue(cache: LocalCache) = UnknownTopNavPerRelayFilterSet
}
@@ -18,15 +18,8 @@
* 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.nip01Core.relay
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
enum class RelayState {
// Websocket connected
CONNECTED,
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
// Websocket disconnecting
DISCONNECTING,
// Websocket disconnected
DISCONNECTED,
}
object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet
@@ -0,0 +1,50 @@
/**
* 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.model.torState
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
class TorRelayEvaluation(
val torSettings: TorRelaySettings,
val trustedRelayList: Set<NormalizedRelayUrl>,
val dmRelayList: Set<NormalizedRelayUrl>,
) {
fun useTor(relay: NormalizedRelayUrl): Boolean {
return if (torSettings.torType == TorType.OFF) {
false
} else {
if (relay.isLocalHost()) {
false
} else if (relay.isOnion()) {
torSettings.onionRelaysViaTor
} else if (relay in dmRelayList) {
torSettings.dmRelaysViaTor
} else if (relay in trustedRelayList) {
torSettings.trustedRelaysViaTor
} else {
torSettings.newRelaysViaTor
}
}
}
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.torState
import com.vitorpamplona.amethyst.ui.tor.TorType
class TorRelaySettings(
val torType: TorType = TorType.OFF,
val onionRelaysViaTor: Boolean = true,
val dmRelaysViaTor: Boolean = false,
val trustedRelaysViaTor: Boolean = false,
val newRelaysViaTor: Boolean = false,
)
@@ -0,0 +1,107 @@
/**
* 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.model.torState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class TorRelayState(
val trustedRelayState: TrustedRelayListsState,
val dmRelayState: DmRelayListState,
val settings: AccountSettings,
val scope: CoroutineScope,
) {
val torSettings =
combine(
settings.torSettings.torType,
settings.torSettings.onionRelaysViaTor,
settings.torSettings.dmRelaysViaTor,
settings.torSettings.trustedRelaysViaTor,
settings.torSettings.newRelaysViaTor,
) {
torType: TorType,
onionRelaysViaTor: Boolean,
dmRelaysViaTor: Boolean,
trustedRelaysViaTor: Boolean,
newRelaysViaTor: Boolean,
->
TorRelaySettings(torType, onionRelaysViaTor, dmRelaysViaTor, trustedRelaysViaTor, newRelaysViaTor)
}.onStart {
emit(
TorRelaySettings(
settings.torSettings.torType.value,
settings.torSettings.onionRelaysViaTor.value,
settings.torSettings.dmRelaysViaTor.value,
settings.torSettings.trustedRelaysViaTor.value,
settings.torSettings.newRelaysViaTor.value,
),
)
}
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
TorRelaySettings(
settings.torSettings.torType.value,
settings.torSettings.onionRelaysViaTor.value,
settings.torSettings.dmRelaysViaTor.value,
settings.torSettings.trustedRelaysViaTor.value,
settings.torSettings.newRelaysViaTor.value,
),
)
val flow =
combineTransform(
torSettings,
trustedRelayState.flow,
dmRelayState.flow,
) { torSettings: TorRelaySettings, trustedRelayList: Set<NormalizedRelayUrl>, dmRelayList: Set<NormalizedRelayUrl> ->
emit(TorRelayEvaluation(torSettings, trustedRelayList, dmRelayList))
}.onStart {
emit(
TorRelayEvaluation(
torSettings.value,
trustedRelayState.flow.value,
dmRelayState.flow.value,
),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
TorRelayEvaluation(
torSettings.value,
trustedRelayState.flow.value,
dmRelayState.flow.value,
),
)
}
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.Call
@@ -49,25 +50,23 @@ object Nip11CachedRetriever {
class RetrieveResultLoading : RetrieveResult(TimeUtils.now())
private val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(100)
private val retriever = Nip11Retriever()
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null
fun getFromCache(relay: NormalizedRelayUrl): Nip11RelayInformation? {
val result = relayInformationDocumentCache.get(relay) ?: return null
if (result is RetrieveResultSuccess) return result.data
return null
}
suspend fun loadRelayInfo(
dirtyUrl: String,
relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
checkNotInMainThread()
val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
val doc = relayInformationDocumentCache.get(relay)
if (doc != null) {
if (doc is RetrieveResultSuccess) {
onInfo(doc.data)
@@ -75,41 +74,37 @@ object Nip11CachedRetriever {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
// just wait.
} else {
retrieve(url, dirtyUrl, okHttpClient, onInfo, onError)
retrieve(relay, okHttpClient, onInfo, onError)
}
} else if (doc is RetrieveResultError) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null)
onError(relay, doc.error, null)
} else {
retrieve(url, dirtyUrl, okHttpClient, onInfo, onError)
retrieve(relay, okHttpClient, onInfo, onError)
}
}
} else {
retrieve(url, dirtyUrl, okHttpClient, onInfo, onError)
retrieve(relay, okHttpClient, onInfo, onError)
}
}
private suspend fun retrieve(
url: String,
dirtyUrl: String,
relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
relayInformationDocumentCache.put(url, RetrieveResultLoading())
relayInformationDocumentCache.put(relay, RetrieveResultLoading())
retriever.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
relay = relay,
okHttpClient = okHttpClient,
onInfo = {
checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
relayInformationDocumentCache.put(relay, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
onError = { relay, code, errorMsg ->
relayInformationDocumentCache.put(relay, RetrieveResultError(code, errorMsg))
onError(relay, code, errorMsg)
},
)
}
@@ -124,13 +119,13 @@ class Nip11Retriever {
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit,
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
) {
checkNotInMainThread()
val url = relay.toHttp()
try {
val request: Request =
Request
@@ -154,16 +149,16 @@ class Nip11Retriever {
if (it.isSuccessful) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay $dirtyUrl in not parseable: $body",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
}
@@ -172,15 +167,15 @@ class Nip11Retriever {
call: Call,
e: IOException,
) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
Log.e("RelayInfoFail", "${relay.url} unavailable", e)
onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
},
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
}
}
@@ -23,13 +23,14 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.collectSuccessfulOperations
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.collectSuccessfulOperations
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -173,6 +175,16 @@ class ZapPaymentHandler(
val user: User? = null,
)
fun receivingRelaySet(userHex: HexKey): Set<NormalizedRelayUrl>? {
return (
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(userHex),
)?.event as? AdvertisedRelayListEvent
)
?.readRelaysNorm()?.toSet()
}
suspend fun signAllZapRequests(
note: Note,
pollOption: Int?,
@@ -181,18 +193,6 @@ class ZapPaymentHandler(
zapsToSend: List<BaseZapSplitSetup>,
onAllDone: suspend (List<ZapRequestReady>) -> Unit,
) {
val authorRelayList =
note.author
?.pubkeyHex
?.let {
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(it),
)?.event as? AdvertisedRelayListEvent?
)?.readRelays()
}?.toSet()
collectSuccessfulOperations<BaseZapSplitSetup, ZapRequestReady>(
items = zapsToSend,
runRequestFor = { next: BaseZapSplitSetup, onReady ->
@@ -203,18 +203,12 @@ class ZapPaymentHandler(
}
}
} else if (next is ZapSplitSetup) {
val user = LocalCache.getUserIfExists(next.pubKeyHex)
val userRelayList =
(
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(next.pubKeyHex),
)?.event as? AdvertisedRelayListEvent?
)?.readRelays()?.toSet() ?: emptySet()
) + (authorRelayList ?: emptySet())
val authorRelayList = note.author?.let { receivingRelaySet(it.pubkeyHex) } ?: emptySet()
val userRelayList = receivingRelaySet(next.pubKeyHex) ?: emptySet()
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
val user = LocalCache.getOrCreateUser(next.pubKeyHex)
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList + authorRelayList) { zapRequestJson ->
onReady(ZapRequestReady(next, zapRequestJson, user))
}
}
@@ -387,7 +381,7 @@ class ZapPaymentHandler(
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
additionalRelays: Set<String>? = null,
additionalRelays: Set<NormalizedRelayUrl>? = null,
onReady: (String?) -> Unit,
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
@@ -25,11 +25,12 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.utils.launchAndWaitAll
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -51,7 +52,7 @@ class RegisterAccounts(
private suspend fun signAllAuths(
notificationToken: String,
remainingTos: List<Pair<AccountSettings, List<String>>>,
remainingTos: List<Pair<AccountSettings, List<NormalizedRelayUrl>>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
@@ -99,15 +100,15 @@ class RegisterAccounts(
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
if (acc != null && acc.isWriteable()) {
val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList()
val nip65Read = acc.backupNIP65RelayList?.readRelaysNorm() ?: emptyList()
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}")
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList()
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}")
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList()
Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
@@ -39,7 +39,7 @@ class DualHttpClientManager(
) {
val factory = OkHttpClientFactory(keyCache)
private val defaultHttpClient: StateFlow<OkHttpClient> =
val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
factory.buildHttpClient(proxy, mobile, userAgent)
}.stateIn(
@@ -48,7 +48,7 @@ class DualHttpClientManager(
factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value, userAgent),
)
private val defaultHttpClientWithoutProxy: StateFlow<OkHttpClient> =
val defaultHttpClientWithoutProxy: StateFlow<OkHttpClient> =
isMobileDataProvider
.map { mobile ->
factory.buildHttpClient(mobile, userAgent)
@@ -20,27 +20,50 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import android.system.Os.socket
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class OkHttpWebSocket(
val url: String,
val forceProxy: Boolean,
val httpClient: (url: String, forceProxy: Boolean) -> OkHttpClient,
val url: NormalizedRelayUrl,
val httpClient: (url: NormalizedRelayUrl) -> OkHttpClient,
val out: WebSocketListener,
) : WebSocket {
private val listener = OkHttpWebsocketListener()
private var usingOkHttp: OkHttpClient? = null
private var socket: okhttp3.WebSocket? = null
fun buildRequest() = Request.Builder().url(url.trim()).build()
fun buildRequest() = Request.Builder().url(url.url).build()
override fun needsReconnect(): Boolean {
val myUsingOkHttp = usingOkHttp
if (myUsingOkHttp == null) return true
val currentOkHttp = httpClient(url)
val usingProxy = myUsingOkHttp.proxy
val currentProxy = currentOkHttp.proxy
if (usingProxy != null && currentProxy != null && usingProxy != currentProxy) return true
if (usingProxy == null && currentProxy != null) return true
if (usingProxy != null && currentProxy == null) return true
if (currentOkHttp.readTimeoutMillis != myUsingOkHttp.readTimeoutMillis) return true
if (currentOkHttp.writeTimeoutMillis != myUsingOkHttp.writeTimeoutMillis) return true
if (currentOkHttp.connectTimeoutMillis != myUsingOkHttp.connectTimeoutMillis) return true
if (currentOkHttp.callTimeoutMillis != myUsingOkHttp.callTimeoutMillis) return true
return false
}
override fun connect() {
socket = httpClient(url, forceProxy).newWebSocket(buildRequest(), listener)
usingOkHttp = httpClient(url)
socket = usingOkHttp?.newWebSocket(buildRequest(), listener)
}
inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() {
@@ -49,7 +72,7 @@ class OkHttpWebSocket(
response: Response,
) = out.onOpen(
response.receivedResponseAtMillis - response.sentRequestAtMillis,
response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false,
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
override fun onMessage(
@@ -73,31 +96,21 @@ class OkHttpWebSocket(
webSocket: okhttp3.WebSocket,
t: Throwable,
response: Response?,
) = out.onFailure(t, response?.message)
) = out.onFailure(t, response?.code, response?.message)
}
class Builder(
val forceProxy: Boolean,
val httpClient: (String, Boolean) -> OkHttpClient,
val httpClient: (NormalizedRelayUrl) -> OkHttpClient,
) : WebsocketBuilder {
// Called when connecting.
override fun build(
url: String,
url: NormalizedRelayUrl,
out: WebSocketListener,
) = OkHttpWebSocket(url, forceProxy, httpClient, out)
) = OkHttpWebSocket(url, httpClient, out)
}
class BuilderFactory(
val httpClient: (String, Boolean) -> OkHttpClient,
) : WebsocketBuilderFactory {
override fun build(
url: String,
forceProxy: Boolean,
) = Builder(forceProxy, httpClient)
}
override fun cancel() {
socket?.cancel()
override fun disconnect() {
socket?.close(1000, "Normal closure")
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false

Some files were not shown because too many files have changed in this diff Show More