Fix unresolved conflicts.

This commit is contained in:
KotlinGeekDev
2025-08-06 13:34:08 +01:00
2289 changed files with 78750 additions and 38258 deletions
+18 -3
View File
@@ -15,11 +15,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
@@ -40,7 +40,7 @@ jobs:
if: ${{ always() }} # IMPORTANT: run Android Test Report regardless
- name: Build APK (gradle)
run: ./gradlew assembleDebug --no-daemon
run: ./gradlew assembleDebug
- name: Upload Play APK
uses: actions/upload-artifact@v4
@@ -54,6 +54,21 @@ jobs:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
- name: Build APK (gradle)
run: ./gradlew assembleBenchmark
- name: Upload Play APK Benchmark
uses: actions/upload-artifact@v4
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-benchmark.apk
- name: Upload FDroid APK Benchmark
uses: actions/upload-artifact@v4
with:
name: FDroid Benchmark APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-benchmark.apk
- name: Upload Compose Reports
uses: actions/upload-artifact@v4
with:
+2 -2
View File
@@ -12,11 +12,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
+1
View File
@@ -14,6 +14,7 @@
/.idea/studiobot.xml
/.idea/other.xml
/.idea/runConfigurations.xml
/.idea/ChatHistory_schema_v2.xml
.DS_Store
/build
/captures
+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>
+5 -3
View File
@@ -17,6 +17,7 @@ Join the social network you control.
[![JitPack version](https://jitpack.io/v/vitorpamplona/amethyst.svg)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst)
## Download and Install
@@ -61,7 +62,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Bech Encoding support (NIP-19)
- [x] Command Results (NIP-20)
- [x] URI Support (NIP-21)
- [x] Long-form Content (NIP-23)
- [x] Long-form Content (NIP-23) (view only)
- [x] User Profile Fields / Relay list (NIP-24)
- [x] Reactions (NIP-25)
- [ ] Delegated Event Signing (NIP-26, Will not implement)
@@ -134,7 +135,8 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Signed Filters (NIP-xx/Draft)
- [ ] Key Migration (NIP-xx/Draft)
- [ ] Time-based Sync (NIP-xx/Draft)
- [ ] Image/Video Capture in the app
- [x] Image Capture in the app
- [ ] Video Capture in the app
- [ ] Local Database
- [ ] Workspaces
- [ ] Infinity Scroll
@@ -173,7 +175,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
+57 -21
View File
@@ -8,21 +8,49 @@ plugins {
alias(libs.plugins.serialization)
}
def getCurrentBranch() {
try {
def branch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim()
return branch
} catch (Exception e) {
println "Could not determine git branch: ${e.message}"
return "unknown"
}
}
def generateVersionName(String baseVersion) {
def currentBranch = getCurrentBranch()
if (currentBranch == "main" || currentBranch == "master") {
return baseVersion
} else {
// Clean branch name for version (replace special characters)
def cleanBranch = currentBranch.replaceAll(/[^a-zA-Z0-9\-_]/, "-")
// Limit branch name to maximum 20 characters
if (cleanBranch.length() > 20) {
cleanBranch = cleanBranch.substring(0, 20)
}
return "${baseVersion}-${cleanBranch}"
}
}
android {
namespace 'com.vitorpamplona.amethyst'
compileSdk libs.versions.android.compileSdk.get().toInteger()
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 418
versionName "0.94.3"
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 418
versionName = generateVersionName("0.94.3")
buildConfigField "String", "RELEASE_NOTES_ID", "\"fd42b23b9ef792059b1c1a89555443abbb11578f4b3c8430b452559eec7325f3\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
useSupportLibrary = true
}
resourceConfigurations += [
'ar',
@@ -107,7 +135,7 @@ android {
buildTypes {
release {
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true
minifyEnabled = true
}
debug {
applicationIdSuffix '.debug'
@@ -119,8 +147,8 @@ android {
applicationIdSuffix '.benchmark'
versionNameSuffix '-BENCHMARK'
resValue "string", "app_name", "@string/app_name_benchmark"
profileable true
signingConfig signingConfigs.debug
profileable = true
signingConfig = signingConfigs.debug
}
}
@@ -139,10 +167,10 @@ android {
splits {
abi {
enable true
enable = true
reset()
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
universalApk true
universalApk = true
}
}
@@ -152,8 +180,8 @@ android {
}
buildFeatures {
compose true
buildConfig true
compose = true
buildConfig = true
}
packagingOptions {
@@ -171,6 +199,17 @@ android {
}
}
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
def tink = "com.google.crypto.tink:tink-android:1.17.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
substitute module('com.google.crypto.tink:tink') using module(tink)
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
@@ -201,9 +240,6 @@ dependencies {
// Navigation
implementation libs.androidx.navigation.compose
// Observe Live data as State
implementation libs.androidx.runtime.livedata
// Material 3 Design
implementation libs.androidx.material3
implementation libs.androidx.material.icons
@@ -216,7 +252,6 @@ dependencies {
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.lifecycle.runtime.compose
implementation libs.androidx.lifecycle.viewmodel.compose
implementation libs.androidx.lifecycle.livedata.ktx
// Zoomable images
implementation libs.zoomable
@@ -226,9 +261,11 @@ dependencies {
// Websockets API
implementation libs.okhttp
implementation libs.okhttpCoroutines
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx
implementation libs.androidx.datastore.preferences
// view videos
implementation libs.androidx.media3.exoplayer
@@ -323,5 +360,4 @@ dependencies {
implementation libs.androidx.camera.lifecycle
implementation libs.androidx.camera.view
implementation libs.androidx.camera.extensions
}
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.service.cashu.CashuParser
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
@@ -38,7 +38,7 @@ class CashuBTest {
@Test()
fun parseCashuA() {
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
assertEquals(cashuTokenA, parsed.token)
assertEquals("https://8333.space:3338", parsed.mint)
@@ -59,7 +59,7 @@ class CashuBTest {
@Test()
fun parseCashuB() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB1, parsed[0].token)
assertEquals("http://localhost:3338", parsed[0].mint)
@@ -84,7 +84,7 @@ class CashuBTest {
@Test()
fun parseCashuB2() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -58,7 +58,6 @@ class DMFileDecryptionTest {
val request =
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -24,8 +24,9 @@ import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
@@ -37,6 +38,8 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.utils.sha256.sha256
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
@@ -48,6 +51,7 @@ import okhttp3.OkHttpClient
import org.junit.Assert
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.Request.method
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
import kotlin.random.Random
@@ -55,10 +59,17 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
companion object {
val account =
Account(
AccountSettings(KeyPair()),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
val accountSettings = AccountSettings(KeyPair())
val signer = NostrSignerInternal(accountSettings.keyPair)
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val cache = LocalCache
val blossomServerListState =
BlossomServerListState(
signer = signer,
cache = cache,
scope = scope,
settings = accountSettings,
)
}
@@ -107,7 +118,7 @@ class ImageUploadTesting {
sensitiveContent = null,
serverBaseUrl = server.baseUrl,
okHttpClient = { client },
httpAuth = account::createBlossomUploadAuth,
httpAuth = blossomServerListState::createBlossomUploadAuth,
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
@@ -135,25 +146,27 @@ class ImageUploadTesting {
{ client },
)
val paylod = getBitmap()
val inputStream = paylod.inputStream()
val payload = getBitmap()
val inputStream = payload.inputStream()
val result =
Nip96Uploader()
.upload(
inputStream = inputStream,
length = paylod.size.toLong(),
length = payload.size.toLong(),
contentType = "image/png",
alt = null,
sensitiveContent = null,
server = serverInfo,
okHttpClient = { client },
onProgress = {},
httpAuth = account::createHTTPAuthorization,
httpAuth = { url, method, body ->
signer.sign(HTTPAuthorizationEvent.build(url, method, body))
},
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
val url = result.url!!
val size = result.size
val size = result.size?.toInt()
val dim = result.dimension
val hash = result.sha256
@@ -179,7 +192,7 @@ class ImageUploadTesting {
assertEquals("${server.name}: Invalid dimensions", it.dim.toString(), dim.toString())
}
if (size != null) {
assertEquals("${server.name}: Invalid size", it.size.toString(), size)
assertEquals("${server.name}: Invalid size", it.size, size)
}
},
onFailure = { fail("${server.name}: It should not fail") },
@@ -221,6 +234,7 @@ class ImageUploadTesting {
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
}
@Ignore("Not Working anymore")
@Test()
fun testNostrBuild() =
runBlocking {
@@ -235,6 +249,7 @@ class ImageUploadTesting {
}
@Test()
@Ignore("Not Working anymore")
fun testVoidCat() =
runBlocking {
testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -25,17 +25,15 @@ import com.vitorpamplona.amethyst.service.ots.OkHttpBitcoinExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class OkHttpOtsTest {
@@ -49,30 +47,30 @@ class OkHttpOtsTest {
val resolver =
OtsResolver(
OkHttpBitcoinExplorer(
OkHttpBitcoinExplorer.MEMPOOL_API_URL,
baseAPI = OkHttpBitcoinExplorer.MEMPOOL_API_URL,
client = OkHttpClient.Builder().build(),
otsCache,
cache = otsCache,
),
OkHttpCalendarBuilder { OkHttpClient.Builder().build() },
)
@Test
fun verifyNostrEvent() {
val ots = EventMapper.fromJson(otsEvent) as OtsEvent
val ots = JsonMapper.fromJson(otsEvent) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(1707688818L, ots.verify(resolver))
}
@Test
fun verifyNostrEvent2() {
val ots = EventMapper.fromJson(otsEvent2) as OtsEvent
val ots = JsonMapper.fromJson(otsEvent2) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(1706322179L, ots.verify(resolver))
}
@Test
fun verifyNostrPendingEvent() {
val ots = EventMapper.fromJson(otsPendingEvent) as OtsEvent
val ots = JsonMapper.fromJson(otsPendingEvent) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(null, ots.verify(resolver))
}
@@ -80,20 +78,15 @@ class OkHttpOtsTest {
@Test
fun createOTSEventAndVerify() {
val signer = NostrSignerInternal(KeyPair())
var ots: OtsEvent? = null
val countDownLatch = CountDownLatch(1)
val otsFile = OtsEvent.stamp(otsEvent2Digest, resolver)
signer.sign(OtsEvent.build(otsEvent2Digest, otsFile)) {
ots = it
countDownLatch.countDown()
}
val ots =
runBlocking {
signer.sign(OtsEvent.build(otsEvent2Digest, otsFile))
}
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(ots!!.toJson())
println(ots.toJson())
println(resolver.info(ots.otsByteArray()))
// Should not be valid because we need to wait for confirmations
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -89,14 +89,14 @@ object PushDistributorHandler : PushDistributorActions {
override fun saveDistributor(distributor: String) {
unifiedPush.saveDistributor(appContext(), distributor)
unifiedPush.registerApp(appContext())
unifiedPush.register(appContext())
}
override fun removeSavedDistributor() {
unifiedPush.safeRemoveDistributor(appContext())
unifiedPush.removeDistributor(appContext())
}
fun forceRemoveDistributor(context: Context) {
unifiedPush.forceRemoveDistributor(context)
UnifiedPush.removeDistributor(context)
}
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -34,7 +34,10 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.MessagingReceiver
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
class PushMessageReceiver : MessagingReceiver() {
companion object {
@@ -48,10 +51,10 @@ class PushMessageReceiver : MessagingReceiver() {
override fun onMessage(
context: Context,
message: ByteArray,
message: PushMessage,
instance: String,
) {
val messageStr = message.decodeToString()
val messageStr = message.content.decodeToString()
Log.d(TAG, "New message $messageStr for Instance: $instance")
scope.launch {
try {
@@ -81,10 +84,10 @@ class PushMessageReceiver : MessagingReceiver() {
override fun onNewEndpoint(
context: Context,
endpoint: String,
endpoint: PushEndpoint,
instance: String,
) {
val sanitizedEndpoint = if (endpoint.endsWith("?up=1")) endpoint.dropLast(5) else endpoint
val sanitizedEndpoint = if (endpoint.url.endsWith("?up=1")) endpoint.url.dropLast(5) else endpoint.url
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
pushHandler.setEndpoint(sanitizedEndpoint)
@@ -102,6 +105,7 @@ class PushMessageReceiver : MessagingReceiver() {
override fun onRegistrationFailed(
context: Context,
reason: FailedReason,
instance: String,
) {
Log.d(TAG, "Registration failed for Instance: $instance")
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
+40 -3
View File
@@ -23,6 +23,9 @@
<!-- To take pictures -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- To record audio messages -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- To read NFCs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.NFC" />
@@ -115,12 +118,44 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="njump.me">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="njump.me" />
</intent-filter>
<intent-filter android:label="Primal">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="primal.net" />
<data android:pathPrefix="/e/" />
<data android:pathPrefix="/p/" />
<data android:pathPrefix="/a/" />
</intent-filter>
<intent-filter android:label="Yakihonne">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="yakihonne.com" />
<data android:pathPrefix="/notes/" />
<data android:pathPrefix="/users/" />
<data android:pathPrefix="/videos/" />
<data android:pathPrefix="/article/" />
</intent-filter>
</activity>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
tools:replace="screenOrientation"
tools:ignore="DiscouragedApi" />
<activity
android:name=".service.playback.pip.PipVideoActivity"
@@ -137,7 +172,8 @@
android:name=".service.playback.service.PlaybackService"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="true"
android:exported="true">
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
@@ -157,6 +193,7 @@
<receiver
android:name=".service.notifications.PokeyReceiver"
android:exported="true"
tools:ignore="ExportedReceiver"
>
<intent-filter>
<action android:name="com.shared.NOSTR" />
@@ -165,4 +202,4 @@
</application>
</manifest>
</manifest>
@@ -1,182 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.adaptive
import androidx.compose.runtime.Immutable
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toComposeRect
import androidx.window.layout.FoldingFeature
/**
* Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework
* folding info into the Material-opinionated posture info.
*/
fun calculatePosture(foldingFeatures: List<FoldingFeature>): Posture {
var isTableTop = false
val hingeList = mutableListOf<HingeInfo>()
@Suppress("ListIterator")
foldingFeatures.forEach {
if (it.orientation == FoldingFeature.Orientation.HORIZONTAL &&
it.state == FoldingFeature.State.HALF_OPENED
) {
isTableTop = true
}
hingeList.add(
HingeInfo(
bounds = it.bounds.toComposeRect(),
isFlat = it.state == FoldingFeature.State.FLAT,
isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
isSeparating = it.isSeparating,
isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL,
),
)
}
return Posture(isTableTop, hingeList)
}
/**
* Posture info that can help make layout adaptation decisions. For example when
* [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any
* content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this
* class in applications, unless you have a strong need of customization that cannot be fulfilled by
* the default implementation.
*
* Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout
* coordinate.
*
* @constructor create an instance of [Posture]
* @property isTabletop `true` if the current window is considered as in the table top mode, i.e.
* there is one half-opened horizontal hinge in the middle of the current window. When
* this is `true` it usually means it's hard for users to interact with the window area
* around the hinge and developers may consider separating the layout along the hinge and
* show software keyboard or other controls in the bottom half of the window.
* @property hingeList a list of all hinges that are relevant to the posture.
*/
@Immutable
class Posture(
val isTabletop: Boolean = false,
val hingeList: List<HingeInfo> = emptyList(),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Posture) return false
if (isTabletop != other.isTabletop) return false
if (hingeList != other.hingeList) return false
return true
}
override fun hashCode(): Int {
var result = isTabletop.hashCode()
result = 31 * result + hingeList.hashCode()
return result
}
override fun toString(): String {
@Suppress("ListIterator")
return "Posture(isTabletop=$isTabletop, " +
"hinges=[${hingeList.joinToString(", ")}])"
}
}
/**
* Returns the list of vertical hinge bounds that are separating.
*/
val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating }
/**
* Returns the list of vertical hinge bounds that are occluding.
*/
val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding }
/**
* Returns the list of all vertical hinge bounds.
*/
val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical }
/**
* Returns the list of horizontal hinge bounds that are separating.
*/
val Posture.separatingHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical && isSeparating }
/**
* Returns the list of horizontal hinge bounds that are occluding.
*/
val Posture.occludingHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical && isOccluding }
/**
* Returns the list of all horizontal hinge bounds.
*/
val Posture.allHorizontalHingeBounds
get() = hingeList.getBounds { !isVertical }
/**
* A class that contains the info of a hinge relevant to a [Posture].
*
* @param bounds the bounds of the hinge in the relevant viewport.
* @param isFlat `true` if the hinge is fully open and the relevant window space presented to the
* user is flat.
* @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into
* left and right; `false` if the hinge is horizontal, i.e., it separates the viewport
* into top and bottom.
* @param isSeparating `true` if the hinge creates two logical display areas.
* @param isOccluding `true` if the hinge conceals part of the display.
*/
@Immutable
class HingeInfo(
val bounds: Rect,
val isFlat: Boolean,
val isVertical: Boolean,
val isSeparating: Boolean,
val isOccluding: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HingeInfo) return false
if (bounds != other.bounds) return false
if (isFlat != other.isFlat) return false
if (isVertical != other.isVertical) return false
if (isSeparating != other.isSeparating) return false
if (isOccluding != other.isOccluding) return false
return true
}
override fun hashCode(): Int {
var result = bounds.hashCode()
result = 31 * result + isFlat.hashCode()
result = 31 * result + isVertical.hashCode()
result = 31 * result + isSeparating.hashCode()
result = 31 * result + isOccluding.hashCode()
return result
}
override fun toString(): String =
"HingeInfo(bounds=$bounds, " +
"isFlat=$isFlat, " +
"isVertical=$isVertical, " +
"isSeparating=$isSeparating, " +
"isOccluding=$isOccluding)"
}
private inline fun List<HingeInfo>.getBounds(predicate: HingeInfo.() -> Boolean): List<Rect> =
@Suppress("ListIterator")
mapNotNull { if (it.predicate()) it.bounds else null }
@@ -1,122 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.adaptive
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import androidx.window.core.layout.WindowSizeClass
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowMetricsCalculator
import kotlinx.coroutines.flow.map
@Composable
fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
val windowSize =
with(LocalDensity.current) {
currentWindowSize().toSize().toDpSize()
}
return WindowAdaptiveInfo(
WindowSizeClass.compute(windowSize.width.value, windowSize.height.value),
calculatePosture(collectFoldingFeaturesAsState().value),
)
}
/**
* Returns and automatically update the current window size from [WindowMetricsCalculator].
*
* @return an [IntSize] that represents the current window size.
*/
@Composable
fun currentWindowSize(): IntSize {
// Observe view configuration changes and recalculate the size class on each change. We can't
// use Activity#onConfigurationChanged as this will sometimes fail to be called on different
// API levels, hence why this function needs to be @Composable so we can observe the
// ComposeView's configuration changes.
LocalConfiguration.current
val windowBounds =
WindowMetricsCalculator
.getOrCreate()
.computeCurrentWindowMetrics(LocalContext.current)
.bounds
return IntSize(windowBounds.width(), windowBounds.height())
}
/**
* Collects the current window folding features from [WindowInfoTracker] in to a [State].
*
* @return a [State] of a [FoldingFeature] list.
*/
@Composable
fun collectFoldingFeaturesAsState(): State<List<FoldingFeature>> {
val context = LocalContext.current
return remember(context) {
if (context is Activity) {
// TODO(b/284347941) remove the instance check after the test bug is fixed.
WindowInfoTracker
.getOrCreate(context)
.windowLayoutInfo(context)
} else {
WindowInfoTracker
.getOrCreate(context)
.windowLayoutInfo(context)
}.map { it.displayFeatures.filterIsInstance<FoldingFeature>() }
}.collectAsState(emptyList())
}
/**
* This class collects window info that affects adaptation decisions. An adaptive layout is supposed
* to use the info from this class to decide how the layout is supposed to be adapted.
*
* @constructor create an instance of [WindowAdaptiveInfo]
* @param windowSizeClass [WindowSizeClass] of the current window.
* @param windowPosture [Posture] of the current window.
*/
@Immutable
class WindowAdaptiveInfo(
val windowSizeClass: WindowSizeClass,
val windowPosture: Posture,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is WindowAdaptiveInfo) return false
if (windowSizeClass != other.windowSizeClass) return false
if (windowPosture != other.windowPosture) return false
return true
}
override fun hashCode(): Int {
var result = windowSizeClass.hashCode()
result = 31 * result + windowPosture.hashCode()
return result
}
override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)"
}
@@ -1,122 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.inspectable
import androidx.compose.ui.unit.Velocity
/**
* A nested scroll modifier that provides scroll events to [state].
*
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
* order to receive scroll events. For example:
*
* @param state The [PullRefreshState] associated with this pull-to-refresh component. The state
* will be updated by this modifier.
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
* @sample androidx.compose.material.samples.PullRefreshSample
*/
fun Modifier.pullRefresh(
state: PullRefreshState,
enabled: Boolean = true,
) = inspectable(
inspectorInfo =
debugInspectorInfo {
name = "pullRefresh"
properties["state"] = state
properties["enabled"] = enabled
},
) {
Modifier.pullRefresh(state::onPull, state::onRelease, enabled)
}
/**
* A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom
* pull refresh components.
*
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
* order to receive scroll events. For example:
*
* @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
* down despite being at the top of a scrollable component), whereas negative delta (swiping up)
* is dispatched first (in case it is needed to push the indicator back up), and then the
* unconsumed delta is passed on to the child. The callback returns how much delta was consumed.
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument. The
* callback returns how much velocity was consumed - in most cases this should only consume
* velocity if pull refresh has been dragged already and the velocity is positive (the fling is
* downwards), as an upwards fling should typically still scroll a scrollable component beneath
* the pullRefresh. This is invoked before any remaining velocity is passed to the child.
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither
* [onPull] nor [onRelease] will be invoked.
* @sample androidx.compose.material.samples.CustomPullRefreshSample
*/
fun Modifier.pullRefresh(
onPull: (pullDelta: Float) -> Float,
onRelease: suspend (flingVelocity: Float) -> Float,
enabled: Boolean = true,
) = inspectable(
inspectorInfo =
debugInspectorInfo {
name = "pullRefresh"
properties["onPull"] = onPull
properties["onRelease"] = onRelease
properties["enabled"] = enabled
},
) {
Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
}
private class PullRefreshNestedScrollConnection(
private val onPull: (pullDelta: Float) -> Float,
private val onRelease: suspend (flingVelocity: Float) -> Float,
private val enabled: Boolean,
) : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset =
when {
!enabled -> Offset.Zero
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset =
when {
!enabled -> Offset.Zero
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity = Velocity(0f, onRelease(available.y))
}
@@ -1,231 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.
*
* @param refreshing A boolean representing whether a refresh is occurring.
* @param state The [PullRefreshState] which controls where and how the indicator will be drawn.
* @param modifier Modifiers for the indicator.
* @param backgroundColor The color of the indicator's background.
* @param contentColor The color of the indicator's arc and arrow.
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
* @sample androidx.compose.material.samples.PullRefreshSample
*/
@Composable
fun PullRefreshIndicator(
refreshing: Boolean,
state: PullRefreshState,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
scale: Boolean = false,
) {
val showElevation by
remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } }
Surface(
modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale),
shape = SpinnerShape,
color = backgroundColor,
shadowElevation = if (showElevation) Elevation else 0.dp,
) {
Crossfade(
targetState = refreshing,
animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS),
) { refreshing ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
val spinnerSize = (ArcRadius + StrokeWidth).times(2)
if (refreshing) {
CircularProgressIndicator(
color = contentColor,
strokeWidth = StrokeWidth,
modifier = Modifier.size(spinnerSize),
)
} else {
CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
}
}
}
}
}
/** Modifier.size MUST be specified. */
@Composable
private fun CircularArrowIndicator(
state: PullRefreshState,
color: Color,
modifier: Modifier,
) {
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
val targetAlpha by
remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } }
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
// Empty semantics for tests
Canvas(modifier.semantics {}) {
val values = ArrowValues(state.progress)
val alpha = alphaState.value
rotate(degrees = values.rotation) {
val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f
val arcBounds =
Rect(
size.center.x - arcRadius,
size.center.y - arcRadius,
size.center.x + arcRadius,
size.center.y + arcRadius,
)
drawArc(
color = color,
alpha = alpha,
startAngle = values.startAngle,
sweepAngle = values.endAngle - values.startAngle,
useCenter = false,
topLeft = arcBounds.topLeft,
size = arcBounds.size,
style =
Stroke(
width = StrokeWidth.toPx(),
cap = StrokeCap.Square,
),
)
drawArrow(path, arcBounds, color, alpha, values)
}
}
}
@Immutable
private class ArrowValues(
val rotation: Float,
val startAngle: Float,
val endAngle: Float,
val scale: Float,
)
private fun ArrowValues(progress: Float): ArrowValues {
// Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.
val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// Calculations based on SwipeRefreshLayout specification.
val endTrim = adjustedPercent * MAX_PROGRESS_ARC
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
val startAngle = rotation * 360
val endAngle = (rotation + endTrim) * 360
val scale = min(1f, adjustedPercent)
return ArrowValues(rotation, startAngle, endAngle, scale)
}
private fun DrawScope.drawArrow(
arrow: Path,
bounds: Rect,
color: Color,
alpha: Float,
values: ArrowValues,
) {
arrow.reset()
arrow.moveTo(0f, 0f) // Move to left corner
arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner
// Line to tip of arrow
arrow.lineTo(
x = ArrowWidth.toPx() * values.scale / 2,
y = ArrowHeight.toPx() * values.scale,
)
val radius = min(bounds.width, bounds.height) / 2f
val inset = ArrowWidth.toPx() * values.scale / 2f
arrow.translate(
Offset(
x = radius + bounds.center.x - inset,
y = bounds.center.y + StrokeWidth.toPx() / 2f,
),
)
arrow.close()
rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) }
}
private const val CROSSFADE_DURATION_MS = 100
private const val MAX_PROGRESS_ARC = 0.8f
private val IndicatorSize = 40.dp
private val SpinnerShape = CircleShape
private val ArcRadius = 7.5.dp
private val StrokeWidth = 2.5.dp
private val ArrowWidth = 10.dp
private val ArrowHeight = 5.dp
private val Elevation = 6.dp
// Values taken from SwipeRefreshLayout
private const val MIN_ALPHA = 0.3f
private const val MAX_ALPHA = 1f
private val AlphaTween = tween<Float>(300, easing = LinearEasing)
@@ -1,76 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.inspectable
/**
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator based
* on the given [PullRefreshState].
*
* @param state The [PullRefreshState] which determines the position of the indicator.
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
*/
fun Modifier.pullRefreshIndicatorTransform(
state: PullRefreshState,
scale: Boolean = false,
) = inspectable(
inspectorInfo =
debugInspectorInfo {
name = "pullRefreshIndicatorTransform"
properties["state"] = state
properties["scale"] = scale
},
) {
Modifier
// Essentially we only want to clip the at the top, so the indicator will not appear when
// the position is 0. It is preferable to clip the indicator as opposed to the layout that
// contains the indicator, as this would also end up clipping shadows drawn by items in a
// list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE
// for the other dimensions to allow for more room for elevation / arbitrary indicators - we
// only ever really want to clip at the top edge.
.drawWithContent {
clipRect(
top = 0f,
left = -Float.MAX_VALUE,
right = Float.MAX_VALUE,
bottom = Float.MAX_VALUE,
) {
this@drawWithContent.drawContent()
}
}.graphicsLayer {
translationY = state.position - size.height
if (scale && !state.refreshing) {
val scaleFraction =
LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f)
scaleX = scaleFraction
scaleY = scaleFraction
}
}
}
@@ -1,234 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package androidx.compose.material3.pullrefresh
import androidx.compose.animation.core.animate
import androidx.compose.foundation.MutatorMutex
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
/**
* Creates a [PullRefreshState] that is remembered across compositions.
*
* Changes to [refreshing] will result in [PullRefreshState] being updated.
*
* @param refreshing A boolean representing whether a refresh is currently occurring.
* @param onRefresh The function to be called to trigger a refresh.
* @param refreshThreshold The threshold below which, if a release occurs, [onRefresh] will be
* called.
* @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
* offset corresponds to the position of the bottom of the indicator.
* @sample androidx.compose.material.samples.PullRefreshSample
*/
@Composable
fun rememberPullRefreshState(
refreshing: Boolean,
onRefresh: () -> Unit,
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
): PullRefreshState {
require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
val scope = rememberCoroutineScope()
val onRefreshState = rememberUpdatedState(onRefresh)
val thresholdPx: Float
val refreshingOffsetPx: Float
with(LocalDensity.current) {
thresholdPx = refreshThreshold.toPx()
refreshingOffsetPx = refreshingOffset.toPx()
}
val state =
remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) }
SideEffect {
state.setRefreshing(refreshing)
state.setThreshold(thresholdPx)
state.setRefreshingOffset(refreshingOffsetPx)
}
return state
}
/**
* A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh
* behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
*
* Provides [progress], a float representing how far the user has pulled as a percentage of the
* refreshThreshold. Values of one or less indicate that the user has not yet pulled past the
* threshold. Values greater than one indicate how far past the threshold the user has pulled.
*
* Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like
* pull-to-refresh behaviour with a custom indicator.
*
* Should be created using [rememberPullRefreshState].
*/
class PullRefreshState
internal constructor(
private val animationScope: CoroutineScope,
private val onRefreshState: State<() -> Unit>,
refreshingOffset: Float,
threshold: Float,
) {
/**
* A float representing how far the user has pulled as a percentage of the refreshThreshold.
*
* If the component has not been pulled at all, progress is zero. If the pull has reached halfway
* to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond
* the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the
* refreshThreshold.
*/
val progress
get() = adjustedDistancePulled / threshold
val refreshing
get() = _refreshing
val position
get() = _position
val threshold
get() = _threshold
private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER }
private var _refreshing by mutableStateOf(false)
private var _position by mutableStateOf(0f)
private var distancePulled by mutableStateOf(0f)
private var _threshold by mutableStateOf(threshold)
private var refreshingOffsetState by mutableStateOf(refreshingOffset)
internal fun onPull(pullDelta: Float): Float {
if (_refreshing) return 0f // Already refreshing, do nothing.
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
_position = calculateIndicatorPosition()
return dragConsumed
}
internal fun onRelease(velocity: Float): Float {
if (refreshing) return 0f // Already refreshing, do nothing
if (adjustedDistancePulled > threshold) {
onRefreshState.value()
}
animateIndicatorTo(0f)
val consumed =
when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
internal fun setRefreshing(refreshing: Boolean) {
if (_refreshing != refreshing) {
_refreshing = refreshing
distancePulled = 0f
animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f)
}
}
internal fun setThreshold(threshold: Float) {
_threshold = threshold
}
internal fun setRefreshingOffset(refreshingOffset: Float) {
if (refreshingOffsetState != refreshingOffset) {
refreshingOffsetState = refreshingOffset
if (refreshing) animateIndicatorTo(refreshingOffset)
}
}
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
// overhead of running through the animation pipeline instead of directly mutating the state.
private val mutatorMutex = MutatorMutex()
private fun animateIndicatorTo(offset: Float) =
animationScope.launch {
mutatorMutex.mutate {
animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value }
}
}
private fun calculateIndicatorPosition(): Float =
when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= threshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = threshold * tensionPercent
threshold + extraOffset
}
}
}
/** Default parameter values for [rememberPullRefreshState]. */
object PullRefreshDefaults {
/**
* If the indicator is below this threshold offset when it is released, a refresh will be
* triggered.
*/
val RefreshThreshold = 80.dp
/** The offset at which the indicator should be rendered whilst a refresh is occurring. */
val RefreshingOffset = 56.dp
}
/**
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which is
* used in calculating the indicator position (when the adjusted distance pulled is less than the
* refresh threshold, it is the indicator position, otherwise the indicator position is derived from
* the progress).
*/
private const val DRAG_MULTIPLIER = 0.5f
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -26,7 +26,9 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -35,12 +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.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.relayClient.speedLogger.RelaySpeedLogger
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
@@ -53,6 +62,7 @@ import java.io.File
class Amethyst : Application() {
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
@@ -60,7 +70,7 @@ class Amethyst : Application() {
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
// Key cache to download and decrypt encrypted files before caching them.
// Key cache service to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
// App services that should be run as soon as there are subscribers to their flows
@@ -68,9 +78,10 @@ class Amethyst : Application() {
val torManager = TorManager(this, applicationIOScope)
val connManager = ConnectivityManager(this, applicationIOScope)
// Service that will run at all times.
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
// creates okHttpClients based on the conditions of the connection and tor status
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
@@ -80,21 +91,56 @@ class Amethyst : Application() {
scope = applicationIOScope,
)
val factory =
OkHttpWebSocket.BuilderFactory { _, useProxy ->
okHttpClients.getHttpClient(useProxy)
val torProxySettingsAnchor = ProxySettingsAnchor()
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
okHttpClients.getHttpClient(torProxySettingsAnchor.useProxy(url))
}
val client: NostrClient = NostrClient(factory)
// Caches all events in Memory
val cache: LocalCache = LocalCache
val serviceManager = ServiceManager(client, applicationIOScope)
// 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, applicationIOScope)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(this) }
// local video cache with disk + memory
val videoCache: VideoCache by lazy { VideoCacheFactory.new(this) }
// image cache in disk for coil
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(this) }
// image cache in memory for coil
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(this) }
// Application-wide ots verification cache
val otsVerifCache by lazy { VerificationStateCache() }
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
override fun onCreate() {
@@ -103,12 +149,15 @@ class Amethyst : Application() {
instance = this
if (isDebug()) {
if (isDebug) {
Logging.setup()
}
// initializes diskcache on an IO thread.
applicationIOScope.launch { videoCache }
applicationIOScope.launch {
diskCache
videoCache
}
// registers to receive events
pokeyReceiver.register(this)
@@ -124,12 +173,11 @@ class Amethyst : Application() {
fun contentResolverFn(): ContentResolver = contentResolver
fun isDebug() = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
fun setImageLoader(shouldUseTor: Boolean?) =
ImageLoaderSetup.setup(this, diskCache, memoryCache, isDebug()) {
shouldUseTor?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false)
fun setImageLoader(shouldUseTor: (String) -> Boolean?) {
ImageLoaderSetup.setup(this, diskCache, memoryCache) { url ->
shouldUseTor(url)?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false)
}
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
@@ -142,7 +190,7 @@ class Amethyst : Application() {
super.onTrimMemory(level)
Log.d("AmethystApp", "onTrimMemory $level")
applicationIOScope.launch(Dispatchers.Default) {
serviceManager.trimMemory()
trimmingService.run(null, LocalPreferences.allSavedAccounts())
}
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -27,44 +27,16 @@ import android.os.Debug
import android.util.Log
import androidx.core.content.getSystemService
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import kotlin.time.DurationUnit
import kotlin.time.measureTimedValue
@Suppress("SENSELESS_COMPARISON")
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() }}") }
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrCommunityDataSource.printCounter()
NostrDiscoveryDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrGeohashDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
NostrSingleChannelDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrVideoDataSource.printCounter()
// 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)
@@ -72,22 +44,31 @@ fun debugState(context: Context) {
val jvmHeapAllocatedMb = totalMemoryMb - freeMemoryMb
Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedMb + "/" + maxMemoryMb + " MB")
Log.d("STATE DUMP", "Total Heap Allocated: $jvmHeapAllocatedMb/$maxMemoryMb MB")
val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)
val maxNative = Debug.getNativeHeapSize() / (1024 * 1024)
Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + "/" + maxNative + " MB")
Log.d("STATE DUMP", "Total Native Heap Allocated: $nativeHeap/$maxNative MB")
val activityManager: ActivityManager? = context.getSystemService()
if (activityManager != null) {
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
val memClass = if (isLargeHeap) activityManager.largeMemoryClass else activityManager.memoryClass
Log.d("STATE DUMP", "Memory Class " + memClass + " MB (largeHeap $isLargeHeap)")
Log.d("STATE DUMP", "Memory Class $memClass MB (largeHeap $isLargeHeap)")
}
Log.d("STATE DUMP", "Connected Relays: " + Amethyst.instance.client.connectedRelays())
Log.d(
"STATE DUMP",
"Connected Relays: " +
Amethyst.instance.client
.relayStatusFlow()
.value.connected.size + "/" +
Amethyst.instance.client
.relayStatusFlow()
.value.available.size,
)
Log.d(
"STATE DUMP",
@@ -95,14 +76,12 @@ fun debugState(context: Context) {
)
Log.d(
"STATE DUMP",
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.size) / (1024 * 1024)} MB",
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.maxSize) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
"Notes: " +
LocalCache.notes.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.event != null }.size +
@@ -112,8 +91,6 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Addressables: " +
LocalCache.addressables.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.event != null }.size +
@@ -123,8 +100,6 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Users: " +
LocalCache.users.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
@@ -147,7 +122,7 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Spam: " +
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentMessages.size(),
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentEventIds.size() + " / " + LocalCache.antiSpam.recentAddressables.size(),
)
Log.d(
@@ -167,10 +142,58 @@ fun debugState(context: Context) {
LocalCache.addressables
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
qttNotes.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
qttNotes.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB ")
}
qttAddressables.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
qttAddressables.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB ")
}
}
inline fun <T> logTime(
debugMessage: String,
minToReportMs: Int = 1,
block: () -> T,
): T =
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
if (elapsed.inWholeMilliseconds > minToReportMs) {
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage")
}
result
} else {
block()
}
inline fun <T> logTime(
debugMessage: (T) -> String,
minToReportMs: Int = 1,
block: () -> T,
): T =
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
if (elapsed.inWholeMilliseconds > minToReportMs) {
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}")
}
result
} else {
block()
}
fun debug(
tag: String,
debugMessage: String,
) {
if (isDebug) {
Log.d(tag, debugMessage)
}
}
inline fun debug(
tag: String,
debugMessage: () -> String,
) {
if (isDebug) {
Log.d(tag, debugMessage())
}
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -27,40 +27,37 @@ 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.AccountLanguagePreferencesInternal
import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal
import com.vitorpamplona.amethyst.model.AccountSecurityPreferencesInternal
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
import com.vitorpamplona.amethyst.model.AccountZapPreferencesInternal
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
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.amethyst.ui.tor.TorType
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
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
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
@@ -69,7 +66,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
// To use plaintext SharedPreferences for debugging, set this to true
@@ -90,14 +86,7 @@ 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 DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val REACTION_CHOICES = "reactionChoices"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
@@ -109,18 +98,22 @@ private object PrefKeys {
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList"
const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList"
const val LATEST_MUTE_LIST = "latestMuteList"
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"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val TOR_SETTINGS = "tor_settings"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
@@ -135,8 +128,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) {
@@ -170,7 +163,7 @@ object LocalPreferences {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
EventMapper.mapper.readValue<List<AccountInfo>>(it)
JsonMapper.mapper.readValue<List<AccountInfo>>(it)
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
@@ -190,7 +183,7 @@ object LocalPreferences {
savedAccounts.emit(migrated)
edit { putString(PrefKeys.ALL_ACCOUNT_INFO, EventMapper.mapper.writeValueAsString(savedAccounts.value)) }
edit { putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.mapper.writeValueAsString(savedAccounts.value)) }
}
}
}
@@ -210,7 +203,7 @@ object LocalPreferences {
.edit {
putString(
PrefKeys.ALL_ACCOUNT_INFO,
EventMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
JsonMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
)
}
}
@@ -264,7 +257,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)
}
}
@@ -277,7 +270,7 @@ object LocalPreferences {
* deleted
*/
@SuppressLint("ApplySharedPref")
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
suspend fun deleteAccount(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
withContext(Dispatchers.IO) {
encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() }
@@ -292,7 +285,7 @@ object LocalPreferences {
}
}
suspend fun updatePrefsForLogin(accountSettings: AccountSettings) {
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
setCurrentAccount(accountSettings)
saveToEncryptedStorage(accountSettings)
}
@@ -314,11 +307,10 @@ 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,
EventMapper.mapper.writeValueAsString(settings.defaultFileServer),
JsonMapper.mapper.writeValueAsString(settings.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
@@ -332,12 +324,12 @@ object LocalPreferences {
)
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
EventMapper.mapper.writeValueAsString(settings.zapPaymentRequest),
JsonMapper.mapper.writeValueAsString(settings.zapPaymentRequest.value?.denormalize()),
)
if (settings.backupContactList != null) {
putString(
PrefKeys.LATEST_CONTACT_LIST,
EventMapper.mapper.writeValueAsString(settings.backupContactList),
JsonMapper.mapper.writeValueAsString(settings.backupContactList),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
@@ -346,7 +338,7 @@ object LocalPreferences {
if (settings.backupUserMetadata != null) {
putString(
PrefKeys.LATEST_USER_METADATA,
EventMapper.mapper.writeValueAsString(settings.backupUserMetadata),
JsonMapper.mapper.writeValueAsString(settings.backupUserMetadata),
)
} else {
remove(PrefKeys.LATEST_USER_METADATA)
@@ -355,7 +347,7 @@ object LocalPreferences {
if (settings.backupDMRelayList != null) {
putString(
PrefKeys.LATEST_DM_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupDMRelayList),
JsonMapper.mapper.writeValueAsString(settings.backupDMRelayList),
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
@@ -364,7 +356,7 @@ object LocalPreferences {
if (settings.backupNIP65RelayList != null) {
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupNIP65RelayList),
JsonMapper.mapper.writeValueAsString(settings.backupNIP65RelayList),
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
@@ -373,14 +365,32 @@ object LocalPreferences {
if (settings.backupSearchRelayList != null) {
putString(
PrefKeys.LATEST_SEARCH_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupSearchRelayList),
JsonMapper.mapper.writeValueAsString(settings.backupSearchRelayList),
)
} else {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
if (settings.localRelayServers.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
if (settings.backupBlockedRelayList != null) {
putString(
PrefKeys.LATEST_BLOCKED_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupBlockedRelayList),
)
} else {
remove(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
}
if (settings.backupTrustedRelayList != null) {
putString(
PrefKeys.LATEST_TRUSTED_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupTrustedRelayList),
)
} else {
remove(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
}
if (settings.localRelayServers.value.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value)
} else {
remove(PrefKeys.LOCAL_RELAY_SERVERS)
}
@@ -388,7 +398,7 @@ object LocalPreferences {
if (settings.backupMuteList != null) {
putString(
PrefKeys.LATEST_MUTE_LIST,
EventMapper.mapper.writeValueAsString(settings.backupMuteList),
JsonMapper.mapper.writeValueAsString(settings.backupMuteList),
)
} else {
remove(PrefKeys.LATEST_MUTE_LIST)
@@ -397,7 +407,7 @@ object LocalPreferences {
if (settings.backupPrivateHomeRelayList != null) {
putString(
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
EventMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
JsonMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
)
} else {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
@@ -406,12 +416,57 @@ object LocalPreferences {
if (settings.backupAppSpecificData != null) {
putString(
PrefKeys.LATEST_APP_SPECIFIC_DATA,
EventMapper.mapper.writeValueAsString(settings.backupAppSpecificData),
JsonMapper.mapper.writeValueAsString(settings.backupAppSpecificData),
)
} else {
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
}
if (settings.backupChannelList != null) {
putString(
PrefKeys.LATEST_CHANNEL_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupChannelList),
)
} else {
remove(PrefKeys.LATEST_CHANNEL_LIST)
}
if (settings.backupCommunityList != null) {
putString(
PrefKeys.LATEST_COMMUNITY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupCommunityList),
)
} else {
remove(PrefKeys.LATEST_COMMUNITY_LIST)
}
if (settings.backupHashtagList != null) {
putString(
PrefKeys.LATEST_HASHTAG_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupHashtagList),
)
} else {
remove(PrefKeys.LATEST_HASHTAG_LIST)
}
if (settings.backupGeohashList != null) {
putString(
PrefKeys.LATEST_GEOHASH_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupGeohashList),
)
} else {
remove(PrefKeys.LATEST_GEOHASH_LIST)
}
if (settings.backupEphemeralChatList != null) {
putString(
PrefKeys.LATEST_EPHEMERAL_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupEphemeralChatList),
)
} else {
remove(PrefKeys.LATEST_EPHEMERAL_LIST)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
@@ -420,7 +475,7 @@ object LocalPreferences {
remove(PrefKeys.USE_PROXY)
remove(PrefKeys.PROXY_PORT)
putString(PrefKeys.TOR_SETTINGS, EventMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
putString(PrefKeys.TOR_SETTINGS, JsonMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
val regularMap =
settings.lastReadPerRoute.value.mapValues {
@@ -429,13 +484,13 @@ object LocalPreferences {
putString(
PrefKeys.LAST_READ_PER_ROUTE,
EventMapper.mapper.writeValueAsString(regularMap),
JsonMapper.mapper.writeValueAsString(regularMap),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putString(
PrefKeys.PENDING_ATTESTATIONS,
EventMapper.mapper.writeValueAsString(settings.pendingAttestations.value),
JsonMapper.mapper.writeValueAsString(settings.pendingAttestations.value),
)
}
}
@@ -450,9 +505,8 @@ object LocalPreferences {
prefs: SharedPreferences = encryptedPreferences(),
) {
Log.d("LocalPreferences", "Saving to shared settings")
with(prefs.edit()) {
putString(PrefKeys.SHARED_SETTINGS, EventMapper.mapper.writeValueAsString(sharedSettings))
apply()
prefs.edit {
putString(PrefKeys.SHARED_SETTINGS, JsonMapper.mapper.writeValueAsString(sharedSettings))
}
}
@@ -460,7 +514,7 @@ object LocalPreferences {
Log.d("LocalPreferences", "Load shared settings")
with(prefs) {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { EventMapper.mapper.readValue<Settings>(it) }
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { JsonMapper.mapper.readValue<Settings>(it) }
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
@@ -468,7 +522,6 @@ object LocalPreferences {
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
e,
)
e.printStackTrace()
null
}
}
@@ -511,7 +564,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 =
@@ -519,13 +572,6 @@ object LocalPreferences {
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultZapType =
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
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]
@@ -537,83 +583,22 @@ object LocalPreferences {
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
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 syncedSettings =
if (latestAppSpecificData != null) {
null
} else {
// previous version. Delete this when ready.
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
AccountSyncedSettingsInternal(
reactions =
AccountReactionPreferencesInternal(
reactionChoices = reactionChoices,
),
zaps =
AccountZapPreferencesInternal(
zapAmountChoices = zapAmountChoices,
defaultZapType = defaultZapType,
),
languages =
AccountLanguagePreferencesInternal(
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
),
security =
AccountSecurityPreferencesInternal(
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
),
)
}
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)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val torSettings =
if (useProxy) {
// old settings, means Orbot
TorSettings(
TorType.EXTERNAL,
getInt(PrefKeys.PROXY_PORT, 9050),
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
)
} else {
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
}
val torSettings = parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
@@ -627,14 +612,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 = MutableStateFlow(zapPaymentRequestServer?.normalize()),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
@@ -643,10 +627,16 @@ object LocalPreferences {
backupNIP65RelayList = latestNip65RelayList,
backupDMRelayList = latestDmRelayList,
backupSearchRelayList = latestSearchRelayList,
backupBlockedRelayList = latestBlockedRelayList,
backupTrustedRelayList = latestTrustedRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupMuteList = latestMuteList,
backupAppSpecificData = latestAppSpecificData,
backupSyncedSettings = syncedSettings,
backupChannelList = latestChannelList,
backupCommunityList = latestCommunityList,
backupHashtagList = latestHashtagList,
backupGeohashList = latestGeohashList,
backupEphemeralChatList = latestEphemeralList,
torSettings = TorSettingsFlow.build(torSettings),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
@@ -667,12 +657,11 @@ object LocalPreferences {
if (T::class.java.isInstance(Event::class.java)) {
Event.fromJson(value) as T?
} else {
EventMapper.mapper.readValue<T?>(value)
JsonMapper.mapper.readValue<T?>(value)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
e.printStackTrace()
null
}
}
@@ -687,7 +676,6 @@ object LocalPreferences {
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
e.printStackTrace()
null
}
}
@@ -1,95 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
/**
* Launches an async coroutine for each item, runs the
* function and waits for everybody to finsih
*/
suspend fun <T> launchAndWaitAll(
items: List<T>,
asyncFunc: suspend (T) -> Unit,
) {
coroutineScope {
val jobs =
items.map { next ->
async {
asyncFunc(next)
}
}
// runs in parallel to avoid overcrowding Amber.
withTimeoutOrNull(15000) {
jobs.joinAll()
}
}
}
/**
* Runs the function and waits for 10 seconds for any result.
*/
suspend inline fun <T> tryAndWait(
timeoutMillis: Long = 10000,
crossinline asyncFunc: (Continuation<T>) -> Unit,
): T? =
withTimeoutOrNull(timeoutMillis) {
suspendCancellableCoroutine { continuation ->
asyncFunc(continuation)
}
}
/**
* Runs an async coroutine for each one of the items,
* runs the request for that item,
* and gathers all the results in the output map.
*/
suspend fun <T, K> collectSuccessfulOperations(
items: List<T>,
runRequestFor: (T, (K) -> Unit) -> Unit,
output: MutableList<K> = mutableListOf(),
onReady: suspend (List<K>) -> Unit,
) {
if (items.isEmpty()) {
onReady(output)
return
}
launchAndWaitAll(items) {
val result =
tryAndWait { continuation ->
runRequestFor(it) { result: K -> continuation.resume(result) }
}
if (result != null) {
output.add(result)
}
}
onReady(output)
}
@@ -1,224 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
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.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@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 val trimmingService = MemoryTrimmingService()
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
Amethyst.instance.setImageLoader(myAccount?.shouldUseTorForImageDownload())
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)
}
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
NostrAccountDataSource.otherAccounts =
runBlocking {
LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
}
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
GlobalScope.launch(Dispatchers.IO) {
delay(3000)
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
NostrVideoDataSource.start()
}
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
isStarted = true
}
}
private fun pause() {
Log.d("ServiceManager", "-- Pausing Relay Services")
collectorJob?.cancel()
collectorJob = null
NostrAccountDataSource.stopSync()
NostrHomeDataSource.stopSync()
NostrChannelDataSource.stopSync()
NostrChatroomDataSource.stopSync()
NostrChatroomListDataSource.stopSync()
NostrDiscoveryDataSource.stopSync()
NostrCommunityDataSource.stopSync()
NostrHashtagDataSource.stopSync()
NostrGeohashDataSource.stopSync()
NostrSearchEventOrUserDataSource.stopSync()
NostrSingleChannelDataSource.stopSync()
NostrSingleEventDataSource.stopSync()
NostrSingleUserDataSource.stopSync()
NostrThreadDataSource.stopSync()
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
client.reconnect(null)
isStarted = false
}
fun cleanObservers() {
LocalCache.cleanObservers()
}
suspend fun trimMemory() {
trimmingService.run(account)
}
// 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
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -20,16 +20,14 @@
*/
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.content.ContentResolver
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
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
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -37,14 +35,26 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip55AndroidSigner.client.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
@@ -54,40 +64,44 @@ import kotlinx.coroutines.flow.update
import java.util.Locale
val DefaultChannels =
setOf(
listOf(
// Anigma's Nostr
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos),
// Amethyst's Group
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
ChannelTag("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 =
val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday)
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds)
val DefaultSignerPermissions =
listOf(
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
RelayUrlFormatter.normalize("wss://search.nos.today"),
Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND),
Permission(CommandType.SIGN_EVENT, DraftEvent.KIND),
Permission(CommandType.NIP04_ENCRYPT),
Permission(CommandType.NIP04_DECRYPT),
Permission(CommandType.NIP44_DECRYPT),
Permission(CommandType.NIP44_DECRYPT),
Permission(CommandType.DECRYPT_ZAP_EVENT),
)
// 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 "
@@ -97,14 +111,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: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -113,19 +126,23 @@ class AccountSettings(
var backupDMRelayList: ChatMessageRelayListEvent? = null,
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
var backupSearchRelayList: SearchRelayListEvent? = null,
var backupBlockedRelayList: BlockedRelayListEvent? = null,
var backupTrustedRelayList: TrustedRelayListEvent? = null,
var backupMuteList: MuteListEvent? = null,
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
var backupAppSpecificData: AppSpecificDataEvent? = null,
backupSyncedSettings: AccountSyncedSettingsInternal? = null, // only exist for migration purposes
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()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
) {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings =
backupSyncedSettings?.let { AccountSyncedSettings(it) }
?: AccountSyncedSettings(AccountSyncedSettingsInternal())
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
class AccountSettingsUpdater(
val accountSettings: AccountSettings?,
@@ -137,25 +154,18 @@ class AccountSettings(
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun createSigner() =
fun createSigner(contentResolver: ContentResolver) =
if (keyPair.privKey != null) {
NostrSignerInternal(keyPair)
} else {
when (val packageName = externalSignerPackageName) {
null -> NostrSignerInternal(keyPair)
else -> {
val externalSignerLauncher = ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName)
// TODO: How to handle the launcher here?
try {
externalSignerLauncher.registerLauncher(
launcher = { },
contentResolver = Amethyst.instance::contentResolverFn,
)
} catch (e: Exception) {
Log.d("AccountSettings", "Failed to initialize external signer", e)
}
NostrSignerExternal(keyPair.pubKey.toHexKey(), externalSignerLauncher)
}
else ->
NostrSignerExternal(
pubKey = keyPair.pubKey.toHexKey(),
packageName = packageName,
contentResolver = contentResolver,
)
}
}
@@ -190,9 +200,9 @@ class AccountSettings(
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean {
if (zapPaymentRequest != newServer) {
zapPaymentRequest = newServer
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean {
if (zapPaymentRequest.value != newServer) {
zapPaymentRequest.tryEmit(newServer)
saveAccountSettings()
return true
}
@@ -214,6 +224,10 @@ class AccountSettings(
// list names
// ---
fun changeDefaultHomeFollowList(name: FeedDefinition) {
changeDefaultHomeFollowList(name.code)
}
fun changeDefaultHomeFollowList(name: String) {
if (defaultHomeFollowList.value != name) {
defaultHomeFollowList.tryEmit(name)
@@ -221,6 +235,10 @@ class AccountSettings(
}
}
fun changeDefaultStoriesFollowList(name: FeedDefinition) {
changeDefaultStoriesFollowList(name.code)
}
fun changeDefaultStoriesFollowList(name: String) {
if (defaultStoriesFollowList.value != name) {
defaultStoriesFollowList.tryEmit(name)
@@ -228,6 +246,10 @@ class AccountSettings(
}
}
fun changeDefaultNotificationFollowList(name: FeedDefinition) {
changeDefaultNotificationFollowList(name.code)
}
fun changeDefaultNotificationFollowList(name: String) {
if (defaultNotificationFollowList.value != name) {
defaultNotificationFollowList.tryEmit(name)
@@ -235,6 +257,10 @@ class AccountSettings(
}
}
fun changeDefaultDiscoveryFollowList(name: FeedDefinition) {
changeDefaultDiscoveryFollowList(name.code)
}
fun changeDefaultDiscoveryFollowList(name: String) {
if (defaultDiscoveryFollowList.value != name) {
defaultDiscoveryFollowList.tryEmit(name)
@@ -245,14 +271,13 @@ class AccountSettings(
// ---
// proxy settings
// ---
fun setTorSettings(newTorSettings: TorSettings): Boolean {
fun setTorSettings(newTorSettings: TorSettings): Boolean =
if (torSettings.update(newTorSettings)) {
saveAccountSettings()
return true
true
} else {
return false
false
}
}
// ---
// language services
@@ -291,8 +316,8 @@ class AccountSettings(
// ----
fun updateLocalRelayServers(servers: Set<String>) {
if (localRelayServers != servers) {
localRelayServers = servers
if (localRelayServers.value != servers) {
localRelayServers.update { servers }
saveAccountSettings()
}
}
@@ -347,6 +372,26 @@ class AccountSettings(
}
}
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupBlockedRelayList?.id != newBlockedRelayList.id) {
backupBlockedRelayList = newBlockedRelayList
saveAccountSettings()
}
}
fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) {
if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupTrustedRelayList?.id != newTrustedRelayList.id) {
backupTrustedRelayList = newTrustedRelayList
saveAccountSettings()
}
}
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
@@ -357,6 +402,56 @@ class AccountSettings(
}
}
fun updateChannelListTo(newChannelList: ChannelListEvent?) {
if (newChannelList == null || newChannelList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupChannelList?.id != newChannelList.id) {
backupChannelList = newChannelList
saveAccountSettings()
}
}
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
// Events might be different objects, we have to compare their ids.
if (backupEphemeralChatList?.id != newEphemeralChatList.id) {
backupEphemeralChatList = newEphemeralChatList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
@@ -462,17 +557,6 @@ class AccountSettings(
}
}
// ----
// local relays
// ----
fun updateLocalRelays(newLocalRelays: Set<RelaySetupInfo>) {
if (!localRelays.equals(newLocalRelays)) {
localRelays = newLocalRelays
saveAccountSettings()
}
}
// ---
// attestations
// ---
@@ -508,11 +592,16 @@ class AccountSettings(
return false
}
fun updateOptOutOptions(
warnReports: Boolean,
filterSpam: Boolean,
): Boolean =
if (syncedSettings.security.updateOptOutOptions(warnReports, filterSpam)) {
fun updateWarnReports(warnReports: Boolean): Boolean =
if (syncedSettings.security.updateWarnReports(warnReports)) {
saveAccountSettings()
true
} else {
false
}
fun updateFilterSpam(filterSpam: Boolean): Boolean =
if (syncedSettings.security.updateFilterSpam(filterSpam)) {
saveAccountSettings()
true
} else {
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -113,6 +113,8 @@ class AccountSyncedSettings(
security.warnAboutPostsWithReports = syncedSettingsInternal.security.warnAboutPostsWithReports
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom - getLanguagesSpokenByUser()
}
@Stable
@@ -136,11 +138,12 @@ class AccountLanguagePreferences(
// language services
// ---
fun toggleDontTranslateFrom(languageCode: String) {
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
} else {
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
}
dontTranslateFrom =
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom.plus(languageCode)
} else {
dontTranslateFrom.minus(languageCode)
}
}
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
@@ -190,17 +193,17 @@ class AccountSecurityPreferences(
return false
}
// ---
// filters
// ---
fun updateOptOutOptions(
warnReports: Boolean,
filterSpam: Boolean,
): Boolean =
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers.value) {
fun updateWarnReports(warnReports: Boolean): Boolean =
if (warnAboutPostsWithReports != warnReports) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers.tryEmit(filterSpam)
true
} else {
false
}
fun updateFilterSpam(filterSpam: Boolean): Boolean =
if (filterSpam != filterSpamFromStrangers.value) {
filterSpamFromStrangers.tryEmit(filterSpam)
true
} else {
false
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -22,75 +22,114 @@ package com.vitorpamplona.amethyst.model
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.Relay
import com.vitorpamplona.ammolite.relays.RelayStats
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.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import kotlinx.coroutines.flow.MutableStateFlow
data class Spammer(
val pubkeyHex: HexKey,
var duplicatedMessages: Set<HexKey>,
)
var duplicatedEventIds: Set<HexKey>,
var duplicatedEventAddresses: Set<Address>,
) {
fun shouldHide() = duplicatedEventIds.size >= 5 || duplicatedEventAddresses.size >= 5
}
class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val recentEventIds = LruCache<Int, String>(2000)
val recentAddressables = LruCache<Int, Address>(2000)
val spamMessages = LruCache<Int, Spammer>(1000)
var active: Boolean = true
fun spamHashCode(event: Event): Int = 31 * event.content.hashCode() + event.tags.contentDeepHashCode()
fun isSpam(
event: Event,
relay: Relay?,
relay: NormalizedRelayUrl?,
): Boolean {
checkNotInMainThread()
if (!active) return false
val idHex = event.id
// if short message, ok
// The idea here is to avoid considering repeated "GM" messages spam.
if (event.content.length < 50) return false
if (event.content.length < 60) return false
// if the message is actually short but because it cites a user/event, the nostr: string is
// really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
// blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19Parser.nip19regex.matcher(event.content).find()) return false
if (event.content.length < 180 && event.content.startsWith("nostr:") && Nip19Parser.nip19regex.matcher(event.content).find()) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
// Considers tags so that same replies to different people don't count.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
val hash = spamHashCode(event)
if (
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
) {
Log.w(
"Potential SPAM Message",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
)
// ignores multiple versions of the same addressable.
if (event is AddressableEvent) {
val address = event.address()
// Log down offenders
logOffender(hash, event)
// normal event
if (
(recentAddressables[hash] != null && recentAddressables[hash] != address) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address))
) {
val existingAddress = recentAddressables[hash]
if (relay != null) {
RelayStats.newSpam(relay.url, njumpLink(NEvent.create(event.id, event.pubKey, event.kind, relay.url)))
val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay))
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
// Log down offenders
val spammer = logOffender(hash, event)
if (spammer.shouldHide() && relay != null) {
RelayStats.newSpam(relay, "$link1 $link2")
}
flowSpam.tryEmit(AntiSpamState(this))
return true
}
flowSpam.tryEmit(AntiSpamState(this))
recentAddressables.put(hash, address)
} else {
// normal event
if (
(recentEventIds[hash] != null && recentEventIds[hash] != event.id) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
) {
val existingEvent = recentEventIds[hash]
return true
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
// Log down offenders
val spammer = logOffender(hash, event)
if (spammer.shouldHide() && relay != null) {
RelayStats.newSpam(relay, "$link1 $link2")
}
flowSpam.tryEmit(AntiSpamState(this))
return true
}
recentEventIds.put(hash, event.id)
}
recentMessages.put(hash, idHex)
return false
}
@@ -98,12 +137,33 @@ class AntiSpamFilter {
private fun logOffender(
hashCode: Int,
event: Event,
) {
if (spamMessages.get(hashCode) == null) {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
): Spammer {
val spammer = spamMessages.get(hashCode)
if (spammer == null) {
val newSpammer =
if (event is AddressableEvent) {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOf(),
duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()),
)
} else {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOf(recentEventIds[hashCode], event.id),
duplicatedEventAddresses = setOf(),
)
}
spamMessages.put(hashCode, newSpammer)
return newSpammer
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages += event.id
if (event is AddressableEvent) {
spammer.duplicatedEventAddresses += event.address()
} else {
spammer.duplicatedEventIds += event.id
}
return spammer
}
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -21,221 +21,87 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
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.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.Dispatchers
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
class PublicChatChannel(
idHex: String,
) : Channel(idHex) {
var event: ChannelCreateEvent? = null
var infoTags = EmptyTagList
var info = ChannelData(null, null, null, null)
override fun relays() = info.relays ?: super.relays()
fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, *relays().toTypedArray())
fun toNostrUri() = "nostr:${toNEvent()}"
fun updateChannelInfo(
creator: User,
event: ChannelCreateEvent,
) {
this.event = event
this.infoTags = event.tags.toImmutableListOfLists()
updateChannelInfo(creator, event.channelInfo(), event.createdAt)
}
fun updateChannelInfo(
creator: User,
event: ChannelMetadataEvent,
) {
this.infoTags = event.tags.toImmutableListOfLists()
updateChannelInfo(creator, event.channelInfo(), event.createdAt)
}
fun updateChannelInfo(
creator: User,
channelInfo: ChannelData,
updatedAt: Long,
) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String = info.name ?: super.toBestDisplayName()
override fun summary(): String? = info.about
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
}
@Stable
class LiveActivitiesChannel(
val address: Address,
) : Channel(address.toValue()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
override fun relays() = info?.allRelayUrls() ?: super.relays()
fun relayHintUrl() = relays().firstOrNull()
fun updateChannelInfo(
creator: User,
channelInfo: LiveActivitiesEvent,
updatedAt: Long,
) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String = info?.title() ?: super.toBestDisplayName()
override fun summary(): String? = info?.summary()
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()
fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, *relays().toTypedArray())
fun toATag() = ATag(address, relayHintUrl())
fun toNostrUri() = "nostr:${toNAddr()}"
}
data class Counter(
var number: Int = 0,
)
@Stable
abstract class Channel(
val idHex: String,
) {
var creator: User? = null
var updatedMetadataAt: Long = 0
abstract class Channel : NotesGatherer {
val notes = LargeCache<HexKey, Note>()
var lastNoteCreatedAt: Long = 0
private var relays = mapOf<RelayBriefInfoCache.RelayBriefInfo, Counter>()
var lastNote: Note? = null
open fun id() = Hex.decode(idHex)
private var relays = mapOf<NormalizedRelayUrl, Counter>()
open fun idNote() = id().toNEvent()
abstract fun toBestDisplayName(): String
open fun idDisplayNote() = idNote().toShortenHex()
open fun toBestDisplayName(): String = idDisplayNote()
open fun summary(): String? = null
open fun creatorName(): String? = creator?.toBestDisplayName()
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,
updatedAt: Long,
) {
this.creator = creator
this.updatedMetadataAt = updatedAt
live.invalidateData()
fun updateChannelInfo() {
flowSet?.metadata?.invalidateData()
}
@Synchronized
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
fun addRelaySync(briefInfo: NormalizedRelayUrl) {
if (briefInfo !in relays) {
relays = relays + Pair(briefInfo, Counter(1))
}
}
fun addRelay(relay: Relay) {
val counter = relays[relay.brief]
fun addRelay(relay: NormalizedRelayUrl) {
val counter = relays[relay]
if (counter != null) {
counter.number++
} else {
addRelaySync(relay.brief)
addRelaySync(relay)
}
}
fun addNote(
note: Note,
relay: Relay? = null,
relay: NormalizedRelayUrl? = null,
) {
notes.put(note.idHex, note)
if (!notes.containsKey(note.idHex)) {
notes.put(note.idHex, note)
note.addGatherer(this)
if ((note.createdAt() ?: 0) > lastNoteCreatedAt) {
lastNoteCreatedAt = note.createdAt() ?: 0
}
if ((note.createdAt() ?: 0) > (lastNote?.createdAt() ?: 0)) {
lastNote = note
}
if (relay != null) {
addRelay(relay)
if (relay != null) {
addRelay(relay)
}
flowSet?.notes?.invalidateData()
}
}
fun removeNote(note: Note) {
notes.remove(note.idHex)
override fun removeNote(note: Note) {
if (notes.containsKey(note.idHex)) {
notes.remove(note.idHex)
note.removeGatherer(this)
if (note == lastNote) {
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
}
flowSet?.notes?.invalidateData()
}
}
fun removeNote(noteHex: String) {
notes.remove(noteHex)
}
abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
fun pruneOldMessages(): Set<Note> {
val important =
notes
.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == false
}.sortedWith(DefaultFeedOrder)
.values()
.sortedWith(DefaultFeedOrder)
.take(500)
.toSet()
@@ -243,38 +109,83 @@ abstract class Channel(
toBeRemoved.forEach { notes.remove(it.idHex) }
flowSet?.notes?.invalidateData()
return toBeRemoved.toSet()
}
}
class ChannelLiveData(
val channel: Channel,
) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun pruneHiddenMessages(account: Account): Set<Note> {
val hidden =
notes
.filter { key, it ->
it.author?.let { author -> account.isHidden(author) } == true
}.toSet()
fun invalidateData() {
checkNotInMainThread()
hidden.forEach { notes.remove(it.idHex) }
bundler.invalidate {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(ChannelState(channel))
flowSet?.notes?.invalidateData()
return hidden.toSet()
}
var flowSet: ChannelFlowSet? = null
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = ChannelFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet = null
}
}
}
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel)
fun flow(): ChannelFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
override fun onInactive() {
super.onInactive()
NostrSingleChannelDataSource.remove(channel)
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
data class Counter(
var number: Int = 0,
)
@Stable
class ChannelFlowSet(
u: Channel,
) {
// Observers line up here.
val metadata = ChannelFlow(u)
val notes = ChannelFlow(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
notes.hasObservers()
}
class ChannelFlow(
val channel: Channel,
) {
val stateFlow = MutableStateFlow(ChannelState(channel))
fun invalidateData() {
stateFlow.tryEmit(ChannelState(channel))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
class ChannelState(
val channel: Channel,
)
@@ -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
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 purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz, bg)
val defaultSearchRelaySet = setOf(band, wine, where)
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -49,20 +49,22 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
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.navigation.navs.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
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
interface MutableMediaAspectRatioCache {
fun get(url: String): Float
fun get(url: String): Float?
fun add(
url: String,
@@ -35,7 +35,7 @@ interface MutableMediaAspectRatioCache {
object MediaAspectRatioCache : MutableMediaAspectRatioCache {
val mediaAspectRatioCacheByUrl = LruCache<String, Float>(1000)
override fun get(url: String) = mediaAspectRatioCacheByUrl.get(url)
override fun get(url: String): Float? = mediaAspectRatioCacheByUrl.get(url)
override fun add(
url: String,
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -22,21 +22,12 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
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.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
@@ -44,7 +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.signers.NostrSigner
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.nip01Core.tags.events.ETag
@@ -57,34 +48,38 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny
import com.vitorpamplona.quartz.utils.launchAndWaitAll
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import java.math.BigDecimal
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
interface NotesGatherer {
fun removeNote(note: Note)
}
@Stable
class AddressableNote(
@@ -94,17 +89,25 @@ class AddressableNote(
override fun toNEvent() = toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun idDisplayNote() = idNote().toShortDisplay()
override fun address() = address
override fun createdAt(): Long? {
if (event == null) return null
val currentEvent = event
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
val lastCreatedAt = event?.createdAt ?: Long.MAX_VALUE
if (currentEvent == null) return null
return minOf(publishedAt, lastCreatedAt)
val publishedAt =
when (currentEvent) {
is LongTextNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is WikiNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is VideoEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is ClassifiedsEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
else -> Long.MAX_VALUE
}
return minOf(publishedAt, currentEvent.createdAt)
}
fun dTag(): String = address.dTag
@@ -125,13 +128,34 @@ class AddressableNote(
@Stable
open class Note(
val idHex: String,
) {
) : NotesGatherer {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
var author: User? = null
var replyTo: List<Note>? = null
var inGatherers: List<NotesGatherer>? = null
fun inGatherers() = inGatherers ?: listOf<NotesGatherer>().also { inGatherers = it }
fun addGatherer(gatherer: NotesGatherer) {
inGatherers = inGatherers() + gatherer
}
fun removeGatherer(gatherer: NotesGatherer) {
inGatherers = inGatherers() - gatherer
}
override fun removeNote(note: Note) {
removeReply(note)
removeBoost(note)
removeReaction(note)
removeZap(note)
removeZapPayment(note)
removeReport(note)
}
// These fields are updated every time an event related to this note is received.
var replies = listOf<Note>()
private set
@@ -153,13 +177,9 @@ open class Note(
var zapPayments = mapOf<Note, Note?>()
private set
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
var relays = listOf<NormalizedRelayUrl>()
private set
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
open fun idNote() = toNEvent()
open fun toNEvent(): String {
@@ -181,14 +201,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
@@ -197,24 +229,7 @@ open class Note(
fun toNostrUri(): String = "nostr:${toNEvent()}"
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? =
if (
event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
event is LiveActivitiesChatMessageEvent ||
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channelId()
?: (event as? ChannelMetadataEvent)?.channelId()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.aTag()?.toTag()
} else {
null
}
open fun idDisplayNote() = idNote().toShortDisplay()
open fun address(): Address? = null
@@ -232,29 +247,36 @@ open class Note(
this.author = author
this.replyTo = replyTo
liveSet?.innerMetadata?.invalidateData()
flowSet?.metadata?.invalidateData()
}
}
fun hasZapsBoostsOrReactions(): Boolean = reactions.isNotEmpty() || zaps.isNotEmpty() || boosts.isNotEmpty()
fun countReactions(): Int {
var total = 0
reactions.forEach { total += it.value.size }
return total
}
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveSet?.innerReplies?.invalidateData()
flowSet?.replies?.invalidateData()
}
}
fun removeReply(note: Note) {
if (note in replies) {
replies = replies - note
liveSet?.innerReplies?.invalidateData()
flowSet?.replies?.invalidateData()
}
}
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.innerBoosts?.invalidateData()
flowSet?.boosts?.invalidateData()
}
}
@@ -282,16 +304,13 @@ open class Note(
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
zapsAmount = BigDecimal.ZERO
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
relays = listOf<NormalizedRelayUrl>()
if (repliesChanged) liveSet?.innerReplies?.invalidateData()
if (reactionsChanged) liveSet?.innerReactions?.invalidateData()
if (boostsChanged) liveSet?.innerBoosts?.invalidateData()
if (reportsChanged) {
flowSet?.reports?.invalidateData()
}
if (zapsChanged) liveSet?.innerZaps?.invalidateData()
if (repliesChanged) flowSet?.replies?.invalidateData()
if (reactionsChanged) flowSet?.reactions?.invalidateData()
if (boostsChanged) flowSet?.boosts?.invalidateData()
if (reportsChanged) flowSet?.reports?.invalidateData()
if (zapsChanged) flowSet?.zaps?.invalidateData()
return toBeRemoved
}
@@ -310,7 +329,7 @@ open class Note(
reactions = reactions + Pair(reaction, newList)
}
liveSet?.innerReactions?.invalidateData()
flowSet?.reactions?.invalidateData()
}
}
}
@@ -331,28 +350,28 @@ open class Note(
if (zaps[note] != null) {
zaps = zaps.minus(note)
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
} else if (zaps.containsValue(note)) {
zaps = zaps.filterValues { it != note }
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
fun removeZapPayment(note: Note) {
if (zapPayments.containsKey(note)) {
zapPayments = zapPayments.minus(note)
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
zapPayments = zapPayments.filterValues { it != note }
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveSet?.innerBoosts?.invalidateData()
flowSet?.boosts?.invalidateData()
}
}
@@ -373,13 +392,11 @@ open class Note(
zapRequest: Note,
zap: Note?,
) {
checkNotInMainThread()
if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
if (inserted && zap != null) {
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
}
@@ -405,7 +422,7 @@ open class Note(
if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
}
@@ -417,10 +434,10 @@ open class Note(
val listOfAuthors = reactions[reaction]
if (listOfAuthors == null) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.innerReactions?.invalidateData()
flowSet?.reactions?.invalidateData()
} else if (!listOfAuthors.contains(note)) {
reactions = reactions + Pair(reaction, listOfAuthors + note)
liveSet?.innerReactions?.invalidateData()
flowSet?.reactions?.invalidateData()
}
}
@@ -439,24 +456,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: Relay) = relay.brief !in relays
fun hasRelay(relay: NormalizedRelayUrl) = relay in relays
fun addRelay(relay: Relay) {
if (relay.brief !in relays) {
addRelaySync(relay.brief)
flowSet?.relays?.invalidateData()
}
}
fun addRelayBrief(brief: RelayBriefInfoCache.RelayBriefInfo) {
if (brief !in relays) {
addRelaySync(brief)
fun addRelay(relay: NormalizedRelayUrl) {
if (relay !in relays) {
addRelaySync(relay)
flowSet?.relays?.invalidateData()
}
}
@@ -476,21 +486,13 @@ open class Note(
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
val result =
tryAndWait { continuation ->
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (
response is PayInvoiceSuccessResponse &&
account.isNIP47Author(zapResponseEvent.requestAuthor())
) {
continuation.resume(true)
}
}
}
account.nip47SignerState.decryptResponse(zapResponseEvent)?.let { response ->
val result = response is PayInvoiceSuccessResponse && account.nip47SignerState.isNIP47Author(zapResponseEvent.requestAuthor())
if (!hasSentOne && result == true) {
hasSentOne = true
onWasZappedByAuthor()
if (!hasSentOne && result == true) {
hasSentOne = true
onWasZappedByAuthor()
}
}
}
}
@@ -507,6 +509,8 @@ open class Note(
return
}
val parallelDecrypt = mutableListOf<Pair<LnZapRequestEvent, LnZapEvent?>>()
zapEvents.forEach { next ->
val zapRequest = next.key.event as LnZapRequestEvent
val zapEvent = next.value?.event as? LnZapEvent
@@ -521,7 +525,7 @@ open class Note(
// private events
// if has already decrypted
val privateZap = zapRequest.cachedPrivateZap()
val privateZap = account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest)
if (privateZap != null) {
if (privateZap.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
@@ -529,21 +533,22 @@ open class Note(
}
} else {
if (account.isWriteable()) {
val result =
tryAndWait { continuation ->
zapRequest.decryptPrivateZap(account.signer) {
continuation.resume(it)
}
}
if (result?.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
return
}
parallelDecrypt.add(Pair(zapRequest, zapEvent))
}
}
}
}
val result =
anyAsync(parallelDecrypt) { pair ->
val result = account.privateZapsDecryptionCache.decryptPrivateZap(pair.first)
result?.pubKey == user.pubkeyHex && (option == null || option == pair.second?.zappedPollOption())
}
if (result) {
onWasZappedByAuthor()
}
}
suspend fun isZappedBy(
@@ -609,26 +614,21 @@ open class Note(
startAmount: BigDecimal,
paidInvoiceSet: LinkedHashSet<String>,
zapPayments: List<Pair<Note, Note?>>,
signer: NostrSigner,
onReady: (BigDecimal) -> Unit,
) {
signerState: NwcSignerState,
): BigDecimal {
if (zapPayments.isEmpty()) {
onReady(startAmount)
return
return startAmount
}
var output: BigDecimal = startAmount
launchAndWaitAll(zapPayments) { next ->
val result =
tryAndWait { continuation ->
processZapAmountFromResponse(
next.first,
next.second,
continuation,
signer,
)
}
processZapAmountFromResponse(
next.first,
next.second,
signerState,
)
if (result != null && !paidInvoiceSet.contains(result.invoice)) {
paidInvoiceSet.add(result.invoice)
@@ -636,27 +636,25 @@ open class Note(
}
}
onReady(output)
return output
}
private fun processZapAmountFromResponse(
private suspend fun processZapAmountFromResponse(
paymentRequest: Note,
paymentResponse: Note?,
continuation: Continuation<InvoiceAmount?>,
signer: NostrSigner,
) {
signerState: NwcSignerState,
): InvoiceAmount? {
val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent
val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent
if (nwcRequest != null && nwcResponse != null) {
return if (nwcRequest != null && nwcResponse != null) {
processZapAmountFromResponse(
nwcRequest,
nwcResponse,
continuation,
signer,
signerState,
)
} else {
continuation.resume(null)
null
}
}
@@ -665,18 +663,19 @@ open class Note(
val amount: BigDecimal,
)
private fun processZapAmountFromResponse(
private suspend fun processZapAmountFromResponse(
nwcRequest: LnZapPaymentRequestEvent,
nwcResponse: LnZapPaymentResponseEvent,
continuation: Continuation<InvoiceAmount?>,
signer: NostrSigner,
) {
signerState: NwcSignerState,
): InvoiceAmount? {
// if we can decrypt the reply
nwcResponse.response(signer) { noteEvent ->
return signerState.decryptResponse(nwcResponse)?.let { noteEvent ->
// if it is a sucess
if (noteEvent is PayInvoiceSuccessResponse) {
// if we can decrypt the invoice
nwcRequest.lnInvoice(signer) { invoice ->
val request = signerState.decryptRequest(nwcRequest)
val invoice = (request as? PayInvoiceMethod)?.params?.invoice
if (invoice != null) {
// if we can parse the amount
val amount =
try {
@@ -688,34 +687,32 @@ open class Note(
// avoid double counting
if (amount != null) {
continuation.resume(InvoiceAmount(invoice, amount))
InvoiceAmount(invoice, amount)
} else {
continuation.resume(null)
null
}
} else {
null
}
} else {
continuation.resume(null)
null
}
}
}
suspend fun zappedAmountWithNWCPayments(
signer: NostrSigner,
onReady: (BigDecimal) -> Unit,
) {
suspend fun zappedAmountWithNWCPayments(signerState: NwcSignerState): BigDecimal {
if (zapPayments.isEmpty()) {
onReady(zapsAmount)
return zapsAmount
}
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } }
zappedAmountCalculation(
return zappedAmountCalculation(
zapsAmount,
invoiceSet,
zapPayments.toList(),
signer,
onReady,
signerState,
)
}
@@ -770,19 +767,27 @@ open class Note(
fun hasReacted(
loggedIn: User,
content: String,
): Boolean = reactedBy(loggedIn, content).isNotEmpty()
): Boolean = allReactionsOfContentByAuthor(loggedIn, content).isNotEmpty()
fun reactedBy(
fun allReactionsOfContentByAuthor(
loggedIn: User,
content: String,
): List<Note> = reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
fun reactedBy(loggedIn: User): List<String> = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
fun allReactionsByAuthor(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 }
@@ -824,11 +829,7 @@ open class Note(
zapsAmount = BigDecimal.ZERO
}
fun clearEOSE() {
lastReactionsDownloadTime = emptyMap()
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
fun isHiddenFor(accountChoices: HiddenUsersState.LiveHiddenUsers): Boolean {
val thisEvent = event ?: return false
val hash = thisEvent.pubKey.hashCode()
@@ -860,6 +861,10 @@ open class Note(
return true
}
if (thisEvent is CommentEvent) {
thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) }
}
if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) {
return true
}
@@ -870,36 +875,8 @@ open class Note(
return false
}
var liveSet: NoteLiveSet? = null
var flowSet: NoteFlowSet? = null
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
fun live(): NoteLiveSet {
if (liveSet == null) {
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
createOrDestroyLiveSync(false)
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
@@ -908,7 +885,6 @@ open class Note(
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
@@ -954,7 +930,14 @@ open class Note(
}
}
fun <T : Event> toEventHint() = (event as? T)?.let { EventHintBundle(it, relayHintUrl(), author?.bestRelayHint()) }
inline fun <reified T : Event> toEventHint(): EventHintBundle<T>? {
val safeEvent = event
return if (safeEvent is T) {
EventHintBundle(safeEvent, relayHintUrl(), author?.bestRelayHint())
} else {
null
}
}
fun toMarkedETag(marker: MarkedETag.MARKER): MarkedETag {
val noteEvent = event
@@ -974,6 +957,12 @@ class NoteFlowSet(
val metadata = NoteBundledRefresherFlow(u)
val reports = NoteBundledRefresherFlow(u)
val relays = NoteBundledRefresherFlow(u)
val reactions = NoteBundledRefresherFlow(u)
val boosts = NoteBundledRefresherFlow(u)
val replies = NoteBundledRefresherFlow(u)
val zaps = NoteBundledRefresherFlow(u)
val ots = NoteBundledRefresherFlow(u)
val edits = NoteBundledRefresherFlow(u)
@OptIn(ExperimentalCoroutinesApi::class)
fun author() =
@@ -984,85 +973,16 @@ class NoteFlowSet(
?.stateFlow ?: MutableStateFlow(null)
}
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
reports.stateFlow.subscriptionCount.value > 0 ||
relays.stateFlow.subscriptionCount.value > 0
fun destroy() {
metadata.destroy()
reports.destroy()
relays.destroy()
}
}
@Stable
class NoteLiveSet(
u: Note,
) {
// Observers line up here.
val innerMetadata = NoteBundledRefresherLiveData(u)
val innerReactions = NoteBundledRefresherLiveData(u)
val innerBoosts = NoteBundledRefresherLiveData(u)
val innerReplies = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
val innerModifications = NoteBundledRefresherLiveData(u)
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
val boosts = innerBoosts.map { it }
val replies = innerReplies.map { it }
val zaps = innerZaps.map { it }
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
val hasReactions =
innerZaps
.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty()
?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged()
val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged()
val reactionCount =
innerReactions
.map {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}.distinctUntilChanged()
val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged()
val content = innerMetadata.map { it.note.event?.content ?: "" }
fun isInUse(): Boolean =
metadata.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
reactions.hasObservers() ||
boosts.hasObservers() ||
replies.hasObservers() ||
zaps.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
innerOts.hasObservers() ||
innerModifications.hasObservers()
fun destroy() {
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerZaps.destroy()
innerOts.destroy()
innerModifications.destroy()
}
ots.hasObservers() ||
edits.hasObservers()
}
@Stable
@@ -1070,78 +990,16 @@ class NoteBundledRefresherFlow(
val note: Note,
) {
// Refreshes observers in batches.
// TODO: Replace the bundler for .sample
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(NoteState(note))
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
stateFlow.emit(NoteState(note))
}
stateFlow.tryEmit(NoteState(note))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
@Stable
class NoteBundledRefresherLiveData(
val note: Note,
) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
postValue(NoteState(note))
}
}
fun <Y> map(transform: (NoteState) -> Y): NoteLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = NoteLoadingLiveData(note, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
@Stable
class NoteLoadingLiveData<Y>(
val note: Note,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
NostrSingleEventDataSource.addAddress(note)
} else {
NostrSingleEventDataSource.add(note)
}
}
override fun onInactive() {
super.onInactive()
if (note is AddressableNote) {
NostrSingleEventDataSource.removeAddress(note)
} else {
NostrSingleEventDataSource.remove(note)
}
}
}
@Immutable class NoteState(
@Immutable
class NoteState(
val note: Note,
)
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -96,7 +96,7 @@ class ParticipantListBuilder {
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
LocalCache.getPublicChatChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -149,3 +149,33 @@ fun parseBooleanType(screenCode: Int): BooleanType =
BooleanType.ALWAYS
}
}
enum class WarningType(
val prefCode: Boolean?,
val screenCode: Int,
val resourceId: Int,
) {
WARN(null, 0, R.string.content_warning_see_warnings_option),
SHOW(true, 1, R.string.content_warning_show_all_sensitive_content_option),
HIDE(false, 2, R.string.content_warning_hide_all_sensitive_content_option),
}
fun parseWarningType(screenCode: Int): WarningType =
when (screenCode) {
WarningType.WARN.screenCode -> WarningType.WARN
WarningType.SHOW.screenCode -> WarningType.SHOW
WarningType.HIDE.screenCode -> WarningType.HIDE
else -> {
WarningType.WARN
}
}
fun parseWarningType(code: Boolean?): WarningType =
when (code) {
WarningType.WARN.prefCode -> WarningType.WARN
WarningType.HIDE.prefCode -> WarningType.HIDE
WarningType.SHOW.prefCode -> WarningType.SHOW
else -> {
WarningType.WARN
}
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -57,14 +57,13 @@ class ThreadAssembler {
// recursive
val roots =
note.replyTo
?.map {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}?.filterNotNull()
note.replyTo?.mapNotNull {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}
if (roots != null && roots.isNotEmpty()) {
return roots[0]
@@ -79,6 +78,18 @@ class ThreadAssembler {
val allNotes: ImmutableSet<Note>,
)
fun findRoot(noteId: String): Note? {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
return if (note.event != null) {
val thread = OnlyLatestVersionSet()
searchRoot(note, thread) ?: note
} else {
note
}
}
fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread()
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -22,29 +22,21 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
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
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
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -52,8 +44,6 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@@ -64,7 +54,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
var followSetNotes: Set<AddressableNote> = setOf()
@@ -73,37 +63,38 @@ class User(
var reports = mapOf<User, Set<Note>>()
private set
var latestEOSEs: Map<String, EOSETime> = emptyMap()
var zaps = mapOf<Note, Note?>()
private set
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
var relaysBeingUsed = mapOf<NormalizedRelayUrl, RelayInfo>()
private set
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay()
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())
fun toNostrUri() = "nostr:${toNProfile()}"
override fun toString(): String = pubkeyHex
fun toBestShortFirstName(): String {
val fullName = toBestDisplayName()
@@ -126,17 +117,6 @@ class User(
fun profilePicture(): String? = info?.picture
fun updateBookmark(event: BookmarkListEvent) {
if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event
liveSet?.innerBookmarks?.invalidateData()
}
fun clearEOSE() {
latestEOSEs = emptyMap()
}
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
@@ -144,7 +124,6 @@ class User(
latestContactList = event
// Update following of the current user
liveSet?.innerFollows?.invalidateData()
flowSet?.follows?.invalidateData()
// Update Followers of the past user list
@@ -152,19 +131,18 @@ class User(
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.flowSet
?.followers
?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.flowSet
?.followers
?.invalidateData()
}
liveSet?.innerRelays?.invalidateData()
flowSet?.relays?.invalidateData()
}
@@ -178,10 +156,10 @@ class User(
val reportsBy = reports[author]
if (reportsBy == null) {
reports = reports + Pair(author, setOf(note))
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
} else if (!reportsBy.contains(note)) {
reports = reports + Pair(author, reportsBy + note)
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
@@ -191,7 +169,7 @@ class User(
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
}
@@ -202,17 +180,17 @@ class User(
) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zaps.containsKey(zapRequestOrZapEvent)) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.innerZaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
@@ -242,80 +220,13 @@ class User(
}
}.flatten()
@Synchronized
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom =
privateChatrooms[key]
?: run {
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom
}
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
return getOrCreatePrivateChatroom(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
fun addMessage(
room: ChatroomKey,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun addMessage(
user: User,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun createChatroom(withKey: ChatroomKey) {
getOrCreatePrivateChatroom(withKey)
}
fun removeMessage(
user: User,
msg: Note,
) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun removeMessage(
room: ChatroomKey,
msg: Note,
) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun addRelayBeingUsed(
relay: Relay,
relay: NormalizedRelayUrl,
eventTime: Long,
) {
val here = relaysBeingUsed[relay.brief.url]
val here = relaysBeingUsed[relay]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay.brief.url, RelayInfo(relay.brief.url, eventTime, 1))
relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1))
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
@@ -323,7 +234,7 @@ class User(
here.counter++
}
liveSet?.innerRelayInfo?.invalidateData()
flowSet?.relayInfo?.invalidateData()
}
fun updateUserInfo(
@@ -343,7 +254,6 @@ class User(
}
flowSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
@@ -356,19 +266,12 @@ class User(
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
return messagesToUser.authors.any { this == it }
}
fun hasReport(
loggedIn: 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 {
@@ -407,36 +310,8 @@ class User(
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
createOrDestroyLiveSync(false)
}
}
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = UserLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
@@ -445,7 +320,6 @@ class User(
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
@@ -473,6 +347,11 @@ class UserFlowSet(
val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
val followers = UserBundledRefresherFlow(u)
val reports = UserBundledRefresherFlow(u)
val relayInfo = UserBundledRefresherFlow(u)
val zaps = UserBundledRefresherFlow(u)
val statuses = UserBundledRefresherFlow(u)
val followSets = UserBundledRefresherFlow(u)
fun isInUse(): Boolean =
@@ -526,106 +405,36 @@ class UserLiveSet(
fun isInUse(): Boolean =
metadata.hasObservers() ||
relays.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
fun destroy() {
innerMetadata.destroy()
innerFollows.destroy()
innerFollowers.destroy()
innerReports.destroy()
innerMessages.destroy()
innerRelays.destroy()
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerStatuses.destroy()
}
statuses.hasObservers()
}
@Immutable
data class RelayInfo(
val url: String,
val url: NormalizedRelayUrl,
var lastEvent: Long,
var counter: Long,
)
class UserBundledRefresherLiveData(
val user: User,
) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
postValue(UserState(user))
}
}
fun <Y> map(transform: (UserState) -> Y): UserLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = UserLoadingLiveData(user, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
@Stable
class UserBundledRefresherFlow(
val user: User,
) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(UserState(user))
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
stateFlow.emit(UserState(user))
}
stateFlow.tryEmit(UserState(user))
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
class UserLoadingLiveData<Y>(
val user: User,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)
}
override fun onInactive() {
super.onInactive()
NostrSingleUserDataSource.remove(user)
}
}
@Immutable class UserState(
@Immutable
class UserState(
val user: User,
)
@@ -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.edits
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
class PrivateStorageRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<PrivateOutboxRelayListEvent>(signer)
@@ -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.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.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
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 decryptionCache: PrivateStorageRelayListDecryptionCache,
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
suspend fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList
return event?.let { decryptionCache.relays(it) } ?: emptySet()
}
val flow =
getPrivateOutboxRelayListFlow()
.map { normalizePrivateOutboxRelayListWithBackup(it.note) }
.onStart {
emit(normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun saveRelayList(relays: List<NormalizedRelayUrl>): PrivateOutboxRelayListEvent {
val relayListForPrivateOutbox = getPrivateOutboxRelayList()
return if (relayListForPrivateOutbox != null) {
PrivateOutboxRelayListEvent.updateRelayList(
earlierVersion = relayListForPrivateOutbox,
relays = relays,
signer = signer,
)
} else {
PrivateOutboxRelayListEvent.create(
relays = relays,
signer = signer,
)
}
}
init {
settings.backupPrivateHomeRelayList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start")
getPrivateOutboxRelayListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}")
(noteState.note.event as? PrivateOutboxRelayListEvent)?.let {
settings.updatePrivateHomeRelayList(it)
}
}
}
}
}
@@ -0,0 +1,36 @@
/**
* 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.emphChat
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@Stable
class EphemeralChatChannel(
val roomId: RoomId,
) : Channel() {
override fun relays() = setOf(roomId.relayUrl)
override fun toBestDisplayName() = roomId.toDisplayKey()
fun anyNameStartsWith(prefix: String): Boolean = roomId.id.contains(prefix, true)
}
@@ -0,0 +1,41 @@
/**
* 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.emphChat
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.roomSet
import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
class EphemeralChatListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<EphemeralChatListEvent>(signer)
fun cachedRoomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).roomSet()
fun cachedRooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).rooms()
suspend fun roomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).roomSet()
suspend fun rooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).rooms()
}
@@ -0,0 +1,130 @@
/**
* 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.emphChat
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.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
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
class EphemeralChatListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: EphemeralChatListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getEphemeralChatListAddress() = EphemeralChatListEvent.createAddress(signer.pubKey)
fun getEphemeralChatListNote(): AddressableNote = cache.getOrCreateAddressableNote(getEphemeralChatListAddress())
fun getEphemeralChatListFlow(): StateFlow<NoteState> = getEphemeralChatListNote().flow().metadata.stateFlow
fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent
suspend fun ephemeralChatListWithBackup(note: Note): Set<RoomId> {
val event = note.event as? EphemeralChatListEvent ?: settings.backupEphemeralChatList
return event?.let { decryptionCache.roomSet(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveEphemeralChatList: StateFlow<Set<RoomId>> =
getEphemeralChatListFlow()
.transformLatest { noteState ->
emit(ephemeralChatListWithBackup(noteState.note))
}.onStart {
emit(ephemeralChatListWithBackup(getEphemeralChatListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun follow(channel: EphemeralChatChannel): EphemeralChatListEvent {
val ephemeralChatList = getEphemeralChatList()
return if (ephemeralChatList == null) {
EphemeralChatListEvent.create(
room = channel.roomId,
isPrivate = true,
signer = signer,
)
} else {
EphemeralChatListEvent.add(
earlierVersion = ephemeralChatList,
room = channel.roomId,
isPrivate = true,
signer = signer,
)
}
}
suspend fun unfollow(channel: EphemeralChatChannel): EphemeralChatListEvent? {
val ephemeralChatList = getEphemeralChatList()
return if (ephemeralChatList != null) {
EphemeralChatListEvent.remove(
earlierVersion = ephemeralChatList,
room = channel.roomId,
signer = signer,
)
} else {
null
}
}
init {
settings.backupEphemeralChatList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start")
getEphemeralChatListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}")
(noteState.note.event as? EphemeralChatListEvent)?.let {
settings.updateEphemeralChatListTo(it)
}
}
}
}
}
@@ -0,0 +1,60 @@
/**
* 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> = 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,58 @@
/**
* 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.nip51Lists.broadcastRelays.BroadcastRelayListState
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 AccountOutboxRelayState(
nip65: Nip65RelayListState,
privateStorage: PrivateStorageRelayListState,
local: LocalRelayListState,
broadcast: BroadcastRelayListState,
scope: CoroutineScope,
) {
val flow =
combine(
nip65.outboxFlow,
privateStorage.flow,
local.flow,
broadcast.flow,
) { nip65Inbox, privateOutBox, localRelays, broadcastRelays ->
nip65Inbox + privateOutBox + localRelays + broadcastRelays
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
nip65.outboxFlow.value +
privateStorage.flow.value +
local.flow.value +
broadcast.flow.value,
)
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -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,123 @@
/**
* 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
suspend 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,
): MetadataEvent {
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,
)
}
return signer.sign(template)
}
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,143 @@
/**
* 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.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
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.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
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.flatten
class FollowListOutboxOrProxyRelays(
kind3Follows: FollowListState,
blockedRelayList: BlockedRelayListState,
proxyRelayList: ProxyRelayListState,
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()
}.flatten()
.toSet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val outboxRelayFlow: StateFlow<Set<NormalizedRelayUrl>> =
kind3Follows.flow
.transformLatest {
emitAll(combineAllFlows(allRelayListFlows(it.authors)))
}.onStart {
emit(
kind3Follows.flow.value.authors
.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()
}.flatten()
.toSet(),
)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
val outboxRelayMinusBlockedFlow: StateFlow<Set<NormalizedRelayUrl>> =
combine(outboxRelayFlow, blockedRelayList.flow) { followList, blockedRelays ->
followList.minus(blockedRelays)
}.onStart {
emit(outboxRelayFlow.value.minus(blockedRelayList.flow.value.toSet()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<NormalizedRelayUrl>> =
proxyRelayList.flow
.flatMapLatest { proxyRelays ->
if (proxyRelays.isEmpty()) {
outboxRelayMinusBlockedFlow
} else {
MutableStateFlow(proxyRelays)
}
}.onStart {
emit(
proxyRelayList.flow.value.ifEmpty {
outboxRelayMinusBlockedFlow.value
},
)
}.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 {
emit(flow.value.map { it.url }.toSet())
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@@ -0,0 +1,161 @@
/**
* 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 ?: settings.backupContactList))
}
val flow =
innerFlow
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
// this has priority.
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(),
)
}
suspend fun follow(user: User): ContactListEvent {
val contactList = getFollowListEvent()
return if (contactList != null) {
ContactListEvent.followUser(contactList, user.pubkeyHex, signer)
} else {
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
relayUse = emptyMap(),
signer = signer,
)
}
}
suspend fun unfollow(user: User): ContactListEvent? {
val contactList = getFollowListEvent()
return if (contactList != null && contactList.tags.isNotEmpty()) {
ContactListEvent.unfollowUser(
contactList,
user.pubkeyHex,
signer,
)
} else {
null
}
}
init {
settings.backupContactList?.let {
Log.d("AccountRegisterObservers", "Loading saved 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,137 @@
/**
* 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.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope
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.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
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 FollowsPerOutboxRelay(
kind3Follows: FollowListState,
blockedRelayList: BlockedRelayListState,
proxyRelayList: ProxyRelayListState,
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 combineAllRelayListFlows(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 outboxPerRelayFlow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
kind3Follows.flow
.transformLatest {
emitAll(combineAllRelayListFlows(allRelayListFlows(it.authors)))
}.onStart {
emit(
mapOfSet {
kind3Follows.flow.value.authors.map { authorHex ->
getNIP65RelayList(authorHex)?.writeRelaysNorm()?.forEach { relay ->
add(relay, authorHex)
}
}
},
)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyMap(),
)
val outboxPerRelayMinusBlockedFlow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
combine(outboxPerRelayFlow, blockedRelayList.flow) { followList, blockedRelays ->
followList.minus(blockedRelays)
}.onStart {
emit(outboxPerRelayFlow.value.minus(blockedRelayList.flow.value.toSet()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyMap(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
proxyRelayList.flow
.flatMapLatest { proxyRelays ->
if (proxyRelays.isEmpty()) {
outboxPerRelayMinusBlockedFlow
} else {
kind3Follows.flow.map { follows ->
proxyRelays.associateWith { follows.authors }
}
}
}.onStart {
if (proxyRelayList.flow.value.isEmpty()) {
emit(outboxPerRelayMinusBlockedFlow.value)
} else {
emit(proxyRelayList.flow.value.associateWith { kind3Follows.flow.value.authors })
}
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyMap(),
)
}
@@ -0,0 +1,89 @@
/**
* 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.nip03Timestamp
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.service.ots.OkHttpOtsResolverBuilder
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.update
import java.util.Base64
class OtsState(
val signer: NostrSigner,
val cache: LocalCache,
val otsResolver: OkHttpOtsResolverBuilder,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
suspend fun updateAttestations(): List<OtsEvent> {
Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations")
return settings.pendingAttestations.value.toList().mapNotNull { (key, value) ->
val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver.build())
if (otsState != null) {
val hint = cache.getNoteIfExists(key)?.toEventHint<Event>()
val template =
if (hint != null) {
OtsEvent.build(hint, otsState)
} else {
OtsEvent.build(key, otsState)
}
val otsEvent = signer.sign(template)
settings.pendingAttestations.update { it - key }
otsEvent
} else {
null
}
}
}
fun hasPendingAttestations(note: Note): Boolean {
val id = note.event?.id ?: note.idHex
return settings.pendingAttestations.value[id] != null
}
fun timestamp(note: Note) {
if (note.isDraft()) return
val id = note.event?.id ?: note.idHex
settings.addPendingAttestation(
id = id,
stamp =
Base64.getEncoder().encodeToString(
OtsEvent.stamp(
id,
otsResolver.build(),
),
),
)
}
}
@@ -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,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.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(),
)
suspend fun saveRelayList(dmRelays: List<NormalizedRelayUrl>): ChatMessageRelayListEvent {
val relayListForDMs = getDMRelayList()
return if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) {
ChatMessageRelayListEvent.updateRelayList(
earlierVersion = relayListForDMs,
relays = dmRelays,
signer = signer,
)
} else {
ChatMessageRelayListEvent.create(
relays = dmRelays,
signer = signer,
)
}
}
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,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.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 {
suspend fun repost(
note: Note,
signer: NostrSigner,
): Event? {
val noteEvent = note.event ?: return null
if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) {
// has already bosted in the past 5mins
return null
}
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)
}
return signer.sign(template)
}
}
}
@@ -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.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: suspend (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 {
onPrivate(
NIP17Factory().createReactionWithinGroup(
emojiUrl = emojiUrl,
originalNote = it,
to = users,
signer = signer,
),
)
}
return
}
}
note.toEventHint<Event>()?.let {
onPrivate(
NIP17Factory().createReactionWithinGroup(
content = reaction,
originalNote = it,
to = users,
signer = signer,
),
)
}
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()))
onPublic(signer.sign(template))
}
return
}
}
note.toEventHint<Event>()?.let {
onPublic(signer.sign(ReactionEvent.build(reaction, it)))
}
}
}
}
}
@@ -0,0 +1,101 @@
/**
* 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.nip28PublicChats
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
@Stable
class PublicChatChannel(
val idHex: String,
) : Channel() {
var creator: User? = null
var event: ChannelCreateEvent? = null
var info = ChannelDataNorm(null, null, null, null)
var infoTags = EmptyTagList
var updatedMetadataAt: Long = 0
override fun relays() = info.relays?.toSet() ?: super.relays()
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(it, relayHintUrl(), null) }
fun toEventId() = EventIdHintOptional(idHex, relayHintUrl())
fun updateChannelInfo(
creator: User,
event: ChannelCreateEvent,
) {
this.creator = creator
this.event = event
this.info = event.channelInfo()
this.infoTags = event.tags.toImmutableListOfLists()
this.updatedMetadataAt = event.createdAt
updateChannelInfo()
}
fun updateChannelInfo(
creator: User,
event: ChannelMetadataEvent,
) {
this.creator = creator
this.info = event.channelInfo()
this.infoTags = event.tags.toImmutableListOfLists()
this.updatedMetadataAt = event.createdAt
super.updateChannelInfo()
}
override fun toBestDisplayName(): String = info.name ?: toNEvent().toShortDisplay()
fun summary(): String? = info.about
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return creator?.info?.banner
return info.picture ?: creator?.info?.banner
}
fun anyNameStartsWith(prefix: String): Boolean =
idHex.startsWith(prefix) ||
info.name?.contains(prefix, true) == true ||
info.about?.contains(prefix, true) == true
}
@@ -0,0 +1,41 @@
/**
* 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.nip28PublicChats
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.channelSet
import com.vitorpamplona.quartz.nip28PublicChat.list.channels
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
class PublicChatListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<ChannelListEvent>(signer)
fun cachedChannelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channelSet()
fun cachedChannels(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channels()
suspend fun channelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channelSet()
suspend fun channels(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channels()
}
@@ -0,0 +1,145 @@
/**
* 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.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.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
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
class PublicChatListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: PublicChatListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey)
fun getChannelListNote(): AddressableNote = cache.getOrCreateAddressableNote(getChannelListAddress())
fun getChannelListFlow(): StateFlow<NoteState> = getChannelListNote().flow().metadata.stateFlow
fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent
suspend fun publicChatListWithBackup(note: Note): Set<ChannelTag> {
val event = note.event as? ChannelListEvent ?: settings.backupChannelList
return event?.let { decryptionCache.channelSet(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<ChannelTag>> =
getChannelListFlow()
.transformLatest { noteState ->
emit(publicChatListWithBackup(noteState.note))
}.onStart {
emit(publicChatListWithBackup(getChannelListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val flowSet: StateFlow<Set<HexKey>> =
flow
.map {
it.mapTo(mutableSetOf()) { it.eventId }
}.onStart {
emit(flow.value.mapTo(mutableSetOf()) { it.eventId })
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun follow(channel: PublicChatChannel): ChannelListEvent {
val publicChatList = getChannelList()
return if (publicChatList == null) {
ChannelListEvent.create(ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer)
} else {
ChannelListEvent.add(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer)
}
}
suspend fun follow(channels: List<PublicChatChannel>): ChannelListEvent {
val publicChatList = getChannelList()
val channelTags = channels.map { ChannelTag(it.idHex, it.relayHintUrl()) }
return if (publicChatList == null) {
ChannelListEvent.create(channelTags, true, signer)
} else {
ChannelListEvent.add(publicChatList, channelTags, true, signer)
}
}
suspend fun unfollow(channel: PublicChatChannel): ChannelListEvent? {
val publicChatList = getChannelList()
return if (publicChatList != null) {
ChannelListEvent.remove(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), signer)
} else {
null
}
}
init {
settings.backupChannelList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
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)
}
}
}
}
}
@@ -0,0 +1,155 @@
/**
* 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.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
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
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.combineTransform
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 EmojiPackState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
class EmojiMedia(
val code: String,
val link: MediaUrlImage,
)
fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(signer.pubKey)
fun getEmojiPackSelection(): EmojiPackSelectionEvent? = getEmojiPackSelectionNote().event as? EmojiPackSelectionEvent
fun getEmojiPackSelectionFlow(): StateFlow<NoteState> = getEmojiPackSelectionNote().flow().metadata.stateFlow
fun getEmojiPackSelectionNote(): AddressableNote = cache.getOrCreateAddressableNote(getEmojiPackSelectionAddress())
fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List<StateFlow<NoteState>>? =
selection?.taggedAddresses()?.map {
cache
.getOrCreateAddressableNote(it)
.flow()
.metadata.stateFlow
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<List<StateFlow<NoteState>>?> =
getEmojiPackSelectionFlow()
.transformLatest {
emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent))
}.onStart {
emit(convertEmojiSelectionPack(getEmojiPackSelection()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> =
pack.taggedEmojis().map {
EmojiMedia(it.code, MediaUrlImage(it.url))
}
fun mergePack(list: Array<NoteState>): List<EmojiMedia> =
list
.mapNotNull {
val ev = it.note.event as? EmojiPackEvent
if (ev != null) {
convertEmojiPack(ev)
} else {
null
}
}.flatten()
.distinctBy { it.link }
@OptIn(ExperimentalCoroutinesApi::class)
val myEmojis =
flow
.transformLatest { emojiList ->
if (emojiList != null) {
emitAll(
combineTransform(emojiList) {
emit(mergePack(it))
},
)
} else {
emit(emptyList())
}
}.onStart {
emit(
mergePack(
convertEmojiSelectionPack(
getEmojiPackSelection(),
)?.map { it.value }?.toTypedArray() ?: emptyArray(),
),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent {
val emojiPackEvent = emojiPack.event
if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
val eventHint = emojiPack.toEventHint<EmojiPackEvent>() ?: throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
val usersEmojiList = getEmojiPackSelection()
return if (usersEmojiList == null) {
val template = EmojiPackSelectionEvent.build(listOf(eventHint))
signer.sign(template)
} else {
val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint)
signer.sign(template)
}
}
suspend fun removeEmojiPack(emojiPack: Note): EmojiPackSelectionEvent? {
val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("Cannot remove an emoji pack to this kind of event.")
val emojiPackEvent = emojiPack.event
if (emojiPackEvent !is EmojiPackEvent) return null
val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent)
return signer.sign(template)
}
}
@@ -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.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 {
suspend fun create(
newStatus: String,
signer: NostrSigner,
): StatusEvent = StatusEvent.create(newStatus, "general", expiration = null, signer)
suspend fun update(
oldStatus: AddressableNote,
newStatus: String,
signer: NostrSigner,
): StatusEvent {
val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event")
return StatusEvent.update(oldEvent, newStatus, signer)
}
suspend fun delete(
oldStatus: AddressableNote,
signer: NostrSigner,
): List<Event> {
val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event")
val event = StatusEvent.clear(oldEvent, signer)
val deletion =
signer.sign(
DeletionEvent.buildForVersionOnly(listOf(event)),
)
return listOf(event, deletion)
}
}
}
@@ -0,0 +1,181 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip47WalletConnect
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentQueryState
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectRequestCache
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account.
*
* Key Responsibilities:
*
* - Dynamically creates a NIP-47 signer if the wallet setup changes in the account settings.
* - Provides decryption caches to manage decrypted NIP-47 requests and responses efficiently.
* - Handles creating of zap payment requests and waits for responses.
*
* @property signer the main Nostr signer used for general Nostr operations
* @property cache the local cache for handling notes and events
* @property scope the coroutine scope used for async operations
* @property settings the account settings containing NIP-47 configuration
*/
class NwcSignerState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
/**
* Derives a NIP-47 signer from the zap payment request in settings.
* If there's no valid configuration, it defaults to the main signer.
* Flows updates whenever settings change.
*/
val nip47Signer =
settings.zapPaymentRequest
.map {
buildSigner(it) ?: signer
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
buildSigner(settings.zapPaymentRequest.value) ?: signer,
)
/**
* Creates a dedicated request decryption cache for the NIP-47 signer.
* Flows updates whenever the signer changes.
*/
val zapPaymentRequestDecryptionCache =
nip47Signer
.map {
NostrWalletConnectRequestCache(it)
}.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectRequestCache(nip47Signer.value))
/**
* Creates a dedicated response decryption cache for the NIP-47 signer.
* Flows updates whenever the signer changes.
*/
val zapPaymentResponseDecryptionCache =
nip47Signer
.map {
NostrWalletConnectResponseCache(it)
}.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectResponseCache(nip47Signer.value))
fun buildSigner(uri: Nip47WalletConnect.Nip47URINorm?) =
uri?.secret?.hexToByteArray()?.let {
NostrSignerInternal(KeyPair(it))
}
fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest.value != null
fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex
/**
* Decrypts a NIP-47 payment request using the current signer.
*
* @param nwcRequest the NIP-47 payment request event to decrypt
* @return the decrypted request or null if not set up or decryption fails
*/
suspend fun decryptRequest(nwcRequest: LnZapPaymentRequestEvent): Request? {
if (!hasWalletConnectSetup()) return null
return zapPaymentRequestDecryptionCache.value.decryptRequest(nwcRequest)
}
/**
* Decrypts a NIP-47 payment response using the current signer.
*
* @param nwsResponse the NIP-47 payment response event to decrypt
* @return the decrypted response or null if not set up or decryption fails
*/
suspend fun decryptResponse(nwsResponse: LnZapPaymentResponseEvent): Response? {
if (!hasWalletConnectSetup()) return null
return zapPaymentResponseDecryptionCache.value.decryptResponse(nwsResponse)
}
/**
* Sends a zap payment request to a connected Lightning wallet.
* Subscribes to responses and waits up to 60s for a reply.
*
* @param bolt11 the BOLT-11 invoice to pay
* @param zappedNote the note being zapped (if any)
* @param onResponse callback to handle the response from the wallet
* @return a pair containing the payment request event and target relay URL
* @throws IllegalArgumentException if no NIP-47 wallet is set up
*/
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = settings.zapPaymentRequest.value
if (walletService == null) throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, signer)
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
Amethyst.instance.sources.nwc
.subscribe(filter)
scope.launch(Dispatchers.IO) {
delay(60000) // waits 1 minute to complete payment.
Amethyst.instance.sources.nwc
.unsubscribe(filter)
}
cache.consume(event, zappedNote, true, walletService.relayUri) {
onResponse(decryptResponse(it))
}
return Pair(event, walletService.relayUri)
}
}
@@ -0,0 +1,276 @@
/**
* 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.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.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
class BookmarkListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
class BookmarkList(
val public: List<Note> = emptyList(),
val private: List<Note> = emptyList(),
)
fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey)
fun getBookmarkListNote() = cache.getOrCreateAddressableNote(getBookmarkListAddress())
fun getBookmarkListFlow(): StateFlow<NoteState> = getBookmarkListNote().flow().metadata.stateFlow
fun getBookmarkList(): BookmarkListEvent? = getBookmarkListNote().event as? BookmarkListEvent
suspend fun publicBookmarks(note: Note): List<BookmarkIdTag> {
val noteEvent = note.event as? BookmarkListEvent
return noteEvent?.publicBookmarks() ?: emptyList()
}
suspend fun privateBookmarks(note: Note): List<BookmarkIdTag> {
val noteEvent = note.event as? BookmarkListEvent
return noteEvent?.privateBookmarks(signer) ?: emptyList()
}
@OptIn(FlowPreview::class)
val publicBookmarks: StateFlow<List<BookmarkIdTag>> =
getBookmarkListFlow()
.map { noteState ->
publicBookmarks(noteState.note)
}.onStart {
emit(publicBookmarks(getBookmarkListNote()))
}.debounce(100)
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
@OptIn(FlowPreview::class)
val privateBookmarks: StateFlow<List<BookmarkIdTag>> =
getBookmarkListFlow()
.map { noteState ->
privateBookmarks(noteState.note)
}.onStart {
emit(privateBookmarks(getBookmarkListNote()))
}.debounce(100)
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val publicBookmarkEventIdSet =
publicBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is EventBookmark) it.eventId else null
}.toSet()
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val publicBookmarkAddressIdSet =
publicBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is AddressBookmark) it.address else null
}.toSet()
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val privateBookmarkEventIdSet =
privateBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is EventBookmark) it.eventId else null
}.toSet()
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val privateBookmarkAddressIdSet =
privateBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is AddressBookmark) it.address else null
}.toSet()
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
fun bookmarkList(
privateBookmarks: List<BookmarkIdTag>,
publicBookmarks: List<BookmarkIdTag>,
): BookmarkList =
BookmarkList(
public =
publicBookmarks
.mapNotNull {
when (it) {
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
}
}.reversed(),
private =
privateBookmarks
.mapNotNull {
when (it) {
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
}
}.reversed(),
)
@OptIn(FlowPreview::class)
val bookmarks: StateFlow<BookmarkList> =
combineTransform(privateBookmarks, publicBookmarks) { private, public ->
emit(bookmarkList(private, public))
}.onStart {
emit(bookmarkList(privateBookmarks.value, publicBookmarks.value))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
BookmarkList(),
)
fun isInPrivateBookmarks(note: Note): Boolean {
if (!signer.isWriteable()) return false
return if (note is AddressableNote) {
privateBookmarkAddressIdSet.value.contains(note.address)
} else {
privateBookmarkEventIdSet.value.contains(note.idHex)
}
}
fun isInPublicBookmarks(note: Note): Boolean =
if (note is AddressableNote) {
publicBookmarkAddressIdSet.value.contains(note.address)
} else {
publicBookmarkEventIdSet.value.contains(note.idHex)
}
suspend fun addBookmark(
note: Note,
isPrivate: Boolean,
): BookmarkListEvent {
val bookmarkList = getBookmarkList()
return if (bookmarkList == null) {
if (note is AddressableNote) {
BookmarkListEvent.create(
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
} else {
BookmarkListEvent.create(
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
}
} else {
if (note is AddressableNote) {
BookmarkListEvent.add(
earlierVersion = bookmarkList,
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
} else {
BookmarkListEvent.add(
earlierVersion = bookmarkList,
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
}
}
}
suspend fun removeBookmark(
note: Note,
isPrivate: Boolean,
): BookmarkListEvent? {
val bookmarkList = getBookmarkList()
return if (bookmarkList != null) {
if (note is AddressableNote) {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
} else {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
}
} else {
null
}
}
}
@@ -0,0 +1,118 @@
/**
* 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.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
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.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class HiddenUsersState(
val muteList: StateFlow<List<MuteTag>>,
val blockList: StateFlow<List<MuteTag>>,
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: List<MuteTag>,
muteList: List<MuteTag>,
transientHiddenUsers: Set<String>,
showSensitiveContent: Boolean?,
): LiveHiddenUsers =
LiveHiddenUsers(
hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null },
hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null },
spammers = transientHiddenUsers,
showSensitiveContent = showSensitiveContent,
)
val flow: StateFlow<LiveHiddenUsers> =
combineTransform(
blockList,
muteList,
transientHiddenUsers,
settings.syncedSettings.security.showSensitiveContent,
) { blockList, muteList, transientHiddenUsers, showSensitiveContent ->
checkNotInMainThread()
emit(assembleLiveHiddenUsers(blockList, muteList, transientHiddenUsers, showSensitiveContent))
}.onStart {
emit(
assembleLiveHiddenUsers(
blockList.value,
muteList.value,
transientHiddenUsers.value,
settings.syncedSettings.security.showSensitiveContent.value,
),
)
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
LiveHiddenUsers(emptySet(), emptySet(), emptySet(), null),
)
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,118 @@
/**
* 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.blockPeopleList
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.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
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 BlockPeopleListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: PeopleListDecryptionCache,
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): List<MuteTag> {
val event = note.event as? PeopleListEvent
return event?.let { decryptionCache.usersAndWords(it) } ?: emptyList()
}
val flow =
getBlockListFlow()
.map { blockListWithBackup(it.note) }
.onStart { emit(blockListWithBackup(getBlockListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun hideUser(pubkeyHex: String): PeopleListEvent {
val blockList = getBlockList()
return if (blockList != null) {
PeopleListEvent.add(
earlierVersion = blockList,
person = UserTag(pubkeyHex),
isPrivate = true,
signer = signer,
)
} else {
PeopleListEvent.create(
name = PeopleListEvent.BLOCK_LIST_D_TAG,
person = UserTag(pubkeyHex),
isPrivate = true,
signer = signer,
dTag = PeopleListEvent.BLOCK_LIST_D_TAG,
)
}
}
suspend fun showUser(pubkeyHex: String): PeopleListEvent? {
val blockList = getBlockList()
return if (blockList != null) {
PeopleListEvent.remove(
earlierVersion = blockList,
person = UserTag(pubkeyHex),
signer = signer,
)
} else {
null
}
}
suspend fun showWord(word: String): PeopleListEvent? {
val blockList = getBlockList()
return if (blockList != null) {
PeopleListEvent.remove(
earlierVersion = blockList,
person = WordTag(word),
signer = signer,
)
} else {
null
}
}
}
@@ -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.nip51Lists.blockPeopleList
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWordSet
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWords
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
class PeopleListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<PeopleListEvent>(signer)
fun cachedUsersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords()
fun cachedUsers(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers()
fun cachedUserIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet()
fun cachedWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords()
fun cachedWordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet()
suspend fun usersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords()
suspend fun users(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers()
suspend fun userIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet()
suspend fun words(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords()
suspend fun wordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet()
}
@@ -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.nip51Lists.blockedRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
class BlockedRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<BlockedRelayListEvent>(signer)
@@ -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.nip51Lists.blockedRelays
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.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
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 BlockedRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: BlockedRelayListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getBlockedRelayListAddress() = BlockedRelayListEvent.createAddress(signer.pubKey)
fun getBlockedRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getBlockedRelayListAddress())
fun getBlockedRelayListFlow(): StateFlow<NoteState> = getBlockedRelayListNote().flow().metadata.stateFlow
fun getBlockedRelayList(): BlockedRelayListEvent? = getBlockedRelayListNote().event as? BlockedRelayListEvent
suspend fun normalizeBlockedRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? BlockedRelayListEvent ?: settings.backupBlockedRelayList
return event?.let { decryptionCache.relays(it) } ?: emptySet()
}
val flow =
getBlockedRelayListFlow()
.map {
normalizeBlockedRelayListWithBackup(it.note)
}.onStart { emit(normalizeBlockedRelayListWithBackup(getBlockedRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun saveRelayList(blockedRelays: List<NormalizedRelayUrl>): BlockedRelayListEvent {
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
val relayListForBlocked = getBlockedRelayList()
return if (relayListForBlocked != null && relayListForBlocked.tags.isNotEmpty()) {
BlockedRelayListEvent.updateRelayList(
earlierVersion = relayListForBlocked,
relays = blockedRelays,
signer = signer,
)
} else {
BlockedRelayListEvent.create(
relays = blockedRelays,
signer = signer,
)
}
}
init {
settings.backupBlockedRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved Blocked relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Blocked Relay List Collector Start")
getBlockedRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Blocked Relay List for ${signer.pubKey}")
(it.note.event as? BlockedRelayListEvent)?.let {
settings.updateBlockedRelayList(it)
}
}
}
}
}
@@ -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.nip51Lists.broadcastRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
class BroadcastRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<BroadcastRelayListEvent>(signer)
@@ -0,0 +1,87 @@
/**
* 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.broadcastRelays
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.nip51Lists.relayLists.BroadcastRelayListEvent
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 BroadcastRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: BroadcastRelayListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getBroadcastRelayListAddress() = BroadcastRelayListEvent.createAddress(signer.pubKey)
fun getBroadcastRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getBroadcastRelayListAddress())
fun getBroadcastRelayListFlow(): StateFlow<NoteState> = getBroadcastRelayListNote().flow().metadata.stateFlow
fun getBroadcastRelayList(): BroadcastRelayListEvent? = getBroadcastRelayListNote().event as? BroadcastRelayListEvent
suspend fun normalizeBroadcastRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? BroadcastRelayListEvent
return event?.let { decryptionCache.relays(it) } ?: emptySet()
}
val flow =
getBroadcastRelayListFlow()
.map { normalizeBroadcastRelayListWithBackup(it.note) }
.onStart { emit(normalizeBroadcastRelayListWithBackup(getBroadcastRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun saveRelayList(broadcastRelays: List<NormalizedRelayUrl>): BroadcastRelayListEvent {
val relayListForBroadcast = getBroadcastRelayList()
return if (relayListForBroadcast != null && relayListForBroadcast.tags.isNotEmpty()) {
BroadcastRelayListEvent.updateRelayList(
earlierVersion = relayListForBroadcast,
relays = broadcastRelays,
signer = signer,
)
} else {
BroadcastRelayListEvent.create(
relays = broadcastRelays,
signer = signer,
)
}
}
}
@@ -1,5 +1,5 @@
/**
* Copyright (c) 2024 Vitor Pamplona
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -18,11 +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.nip51Lists.geohashLists
import com.vitorpamplona.ammolite.relays.filters.IPerRelayFilter
class TypedFilter(
val types: Set<FeedType>,
val filter: IPerRelayFilter,
data class GeohashListCard(
val relays: List<String>,
)
val EmptyGeohashListCard = GeohashListCard(emptyList())
@@ -0,0 +1,76 @@
/**
* 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.geohashLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.geohashSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
class GeohashListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<GeohashListEvent>(signer)
fun cachedGeohashes(event: GeohashListEvent) = cachedPrivateLists.mergeTagListPrecached(event).geohashSet()
suspend fun geohashes(event: GeohashListEvent) = cachedPrivateLists.mergeTagList(event).geohashSet()
fun fastStartValueForGeohashList(note: Note): GeohashListCard {
val noteEvent = note.event as? GeohashListEvent
return if (noteEvent != null) {
GeohashListCard(cachedGeohashes(noteEvent).toList())
} else {
EmptyGeohashListCard
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun observeDecryptedGeohashList(note: Note): Flow<GeohashListCard> =
note
.flow()
.metadata.stateFlow
.mapLatest { noteState ->
val event = noteState.note.event as? GeohashListEvent
GeohashListCard(event?.let { geohashes(it).toList() } ?: emptyList())
}.onStart {
val event = note.event as? GeohashListEvent
if (event != null) {
val list = geohashes(event)
if (list.isNotEmpty()) {
emit(GeohashListCard(list.toList()))
} else {
emit(EmptyGeohashListCard)
}
} else {
emit(EmptyGeohashListCard)
}
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
@@ -0,0 +1,127 @@
/**
* 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.geohashLists
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.geohashList.GeohashListEvent
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
class GeohashListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: GeohashListDecryptionCache,
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> {
val event = note.event as? GeohashListEvent ?: settings.backupGeohashList
return event?.let { decryptionCache.geohashes(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<String>> =
getGeohashListFlow()
.transformLatest { noteState ->
emit(geohashListWithBackup(noteState.note))
}.onStart {
emit(geohashListWithBackup(getGeohashListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun follow(geohashes: List<String>): GeohashListEvent {
val geohashList = getGeohashList()
return if (geohashList == null) {
GeohashListEvent.create(geohashes, true, signer)
} else {
GeohashListEvent.add(geohashList, geohashes, true, signer)
}
}
suspend fun follow(geohash: String): GeohashListEvent {
val geohashList = getGeohashList()
return if (geohashList == null) {
GeohashListEvent.create(geohash, true, signer)
} else {
GeohashListEvent.add(geohashList, geohash, true, signer)
}
}
suspend fun unfollow(geohash: String): GeohashListEvent? {
val geohashList = getGeohashList()
return if (geohashList != null) {
GeohashListEvent.remove(geohashList, geohash, signer)
} else {
null
}
}
init {
settings.backupGeohashList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Geohash List Collector Start")
getGeohashListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}")
(noteState.note.event as? GeohashListEvent)?.let {
settings.updateGeohashListTo(it)
}
}
}
}
}
@@ -0,0 +1,36 @@
/**
* 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.hashtagLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.hashtagSet
class HashtagListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<HashtagListEvent>(signer)
fun cachedHashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagListPrecached(event).hashtagSet()
suspend fun hashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagList(event).hashtagSet()
}
@@ -0,0 +1,127 @@
/**
* 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.hashtagLists
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.hashtagList.HashtagListEvent
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
class HashtagListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: HashtagListDecryptionCache,
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> {
val event = note.event as? HashtagListEvent ?: settings.backupHashtagList
return event?.let { decryptionCache.hashtags(it) } ?: emptySet()
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<String>> =
getHashtagListFlow()
.transformLatest { noteState ->
emit(hashtagListWithBackup(noteState.note))
}.onStart {
emit(hashtagListWithBackup(getHashtagListNote()))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Companion.Eagerly,
emptySet(),
)
suspend fun follow(hashtags: List<String>): HashtagListEvent {
val hashtagList = getHashtagList()
return if (hashtagList == null) {
HashtagListEvent.Companion.create(hashtags, true, signer)
} else {
HashtagListEvent.Companion.add(hashtagList, hashtags, true, signer)
}
}
suspend fun follow(hashtag: String): HashtagListEvent {
val hashtagList = getHashtagList()
return if (hashtagList == null) {
HashtagListEvent.Companion.create(hashtag, true, signer)
} else {
HashtagListEvent.Companion.add(hashtagList, hashtag, true, signer)
}
}
suspend fun unfollow(hashtag: String): HashtagListEvent? {
val hashtagList = getHashtagList()
return if (hashtagList != null) {
HashtagListEvent.Companion.remove(hashtagList, hashtag, signer)
} else {
null
}
}
init {
settings.backupHashtagList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
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,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.nip51Lists.indexerRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
class IndexerRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<IndexerRelayListEvent>(signer)
@@ -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.nip51Lists.indexerRelays
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.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
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 IndexerRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: IndexerRelayListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getIndexerRelayListAddress() = IndexerRelayListEvent.createAddress(signer.pubKey)
fun getIndexerRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getIndexerRelayListAddress())
fun getIndexerRelayListFlow(): StateFlow<NoteState> = getIndexerRelayListNote().flow().metadata.stateFlow
fun getIndexerRelayList(): IndexerRelayListEvent? = getIndexerRelayListNote().event as? IndexerRelayListEvent
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? IndexerRelayListEvent
return event?.let { decryptionCache.relays(it) } ?: emptySet()
}
val flow =
getIndexerRelayListFlow()
.map { normalizeIndexerRelayListWithBackup(it.note) }
.onStart { emit(normalizeIndexerRelayListWithBackup(getIndexerRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun saveRelayList(indexerRelays: List<NormalizedRelayUrl>): IndexerRelayListEvent {
val relayListForIndexer = getIndexerRelayList()
return if (relayListForIndexer != null && relayListForIndexer.tags.isNotEmpty()) {
IndexerRelayListEvent.updateRelayList(
earlierVersion = relayListForIndexer,
relays = indexerRelays,
signer = signer,
)
} else {
IndexerRelayListEvent.create(
relays = indexerRelays,
signer = signer,
)
}
}
}
@@ -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.nip51Lists.muteList
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWordSet
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWords
class MuteListDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<MuteListEvent>(signer)
fun cachedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords()
fun cachedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers()
fun cachedUserIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet()
fun cachedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords()
fun cachedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet()
suspend fun mutedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords()
suspend fun mutedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers()
suspend fun mutedUserIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet()
suspend fun mutedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords()
suspend fun mutedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet()
}
@@ -0,0 +1,161 @@
/**
* 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.muteList
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.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
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 MuteListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: MuteListDecryptionCache,
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): List<MuteTag> {
val event = note.event as? MuteListEvent ?: settings.backupMuteList
return event?.let { decryptionCache.mutedUsersAndWords(it) } ?: emptyList()
}
val flow =
getMuteListFlow()
.map { muteListWithBackup(it.note) }
.onStart { emit(muteListWithBackup(getMuteListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
suspend fun hideUser(pubkeyHex: String): MuteListEvent {
val muteList = getMuteList()
return if (muteList != null) {
MuteListEvent.add(
earlierVersion = muteList,
mute = UserTag(pubkeyHex),
isPrivate = true,
signer = signer,
)
} else {
MuteListEvent.create(
mute = UserTag(pubkeyHex),
isPrivate = true,
signer = signer,
)
}
}
suspend fun showUser(pubkeyHex: String): MuteListEvent? {
val muteList = getMuteList()
return if (muteList != null) {
MuteListEvent.remove(
earlierVersion = muteList,
mute = UserTag(pubkeyHex),
signer = signer,
)
} else {
null
}
}
suspend fun hideWord(word: String): MuteListEvent {
val muteList = getMuteList()
return if (muteList != null) {
MuteListEvent.add(
earlierVersion = muteList,
mute = WordTag(word),
isPrivate = true,
signer = signer,
)
} else {
MuteListEvent.create(
mute = WordTag(word),
isPrivate = true,
signer = signer,
)
}
}
suspend fun showWord(word: String): MuteListEvent? {
val muteList = getMuteList()
return if (muteList != null) {
MuteListEvent.remove(
earlierVersion = muteList,
mute = WordTag(word),
signer = signer,
)
} else {
null
}
}
init {
settings.backupMuteList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
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,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.nip51Lists.proxyRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
class ProxyRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<ProxyRelayListEvent>(signer)
@@ -0,0 +1,87 @@
/**
* 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.proxyRelays
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.nip51Lists.relayLists.ProxyRelayListEvent
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 ProxyRelayListState(
val signer: NostrSigner,
val cache: LocalCache,
val decryptionCache: ProxyRelayListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getProxyRelayListAddress() = ProxyRelayListEvent.createAddress(signer.pubKey)
fun getProxyRelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getProxyRelayListAddress())
fun getProxyRelayListFlow(): StateFlow<NoteState> = getProxyRelayListNote().flow().metadata.stateFlow
fun getProxyRelayList(): ProxyRelayListEvent? = getProxyRelayListNote().event as? ProxyRelayListEvent
suspend fun normalizeProxyRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? ProxyRelayListEvent
return event?.let { decryptionCache.relays(it) } ?: emptySet()
}
val flow =
getProxyRelayListFlow()
.map { normalizeProxyRelayListWithBackup(it.note) }
.onStart { emit(normalizeProxyRelayListWithBackup(getProxyRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
suspend fun saveRelayList(proxyRelays: List<NormalizedRelayUrl>): ProxyRelayListEvent {
val relayListForProxy = getProxyRelayList()
return if (relayListForProxy != null && relayListForProxy.tags.isNotEmpty()) {
ProxyRelayListEvent.updateRelayList(
earlierVersion = relayListForProxy,
relays = proxyRelays,
signer = signer,
)
} else {
ProxyRelayListEvent.create(
relays = proxyRelays,
signer = signer,
)
}
}
}
@@ -0,0 +1,76 @@
/**
* 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.relayLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relaySet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
open class GenericRelayListCache<T : PrivateTagArrayEvent>(
val signer: NostrSigner,
) {
val cachedPrivateLists = PrivateTagArrayEventCache<T>(signer)
fun cachedRelays(event: T) = cachedPrivateLists.mergeTagListPrecached(event).relaySet()
suspend fun relays(event: T) = cachedPrivateLists.mergeTagList(event).relaySet()
fun fastStartValueForRelayList(note: Note): RelayListCard {
val noteEvent = note.event as? T
return if (noteEvent != null) {
RelayListCard(cachedRelays(noteEvent).toList())
} else {
EmptyRelayListCard
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun observeDecryptedRelayList(note: Note): Flow<RelayListCard> =
note
.flow()
.metadata.stateFlow
.mapLatest { noteState ->
val event = noteState.note.event as? T
RelayListCard(event?.let { relays(it).toList() } ?: emptyList())
}.onStart {
val event = note.event as? T
if (event != null) {
val list = relays(event)
if (list.isNotEmpty()) {
emit(RelayListCard(list.toList()))
} else {
emit(EmptyRelayListCard)
}
} else {
emit(EmptyRelayListCard)
}
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
@@ -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.nip51Lists.relayLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Immutable
data class RelayListCard(
val relays: List<NormalizedRelayUrl>,
)
val EmptyRelayListCard = RelayListCard(emptyList())
@@ -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.nip51Lists.searchRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
class SearchRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<SearchRelayListEvent>(signer)
@@ -0,0 +1,110 @@
/**
* 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.searchRelays
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 decryptionCache: SearchRelayListDecryptionCache,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
fun getSearchRelayListAddress() = SearchRelayListEvent.Companion.createAddress(signer.pubKey)
fun getSearchRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getSearchRelayListAddress())
fun getSearchRelayListFlow(): StateFlow<NoteState> = getSearchRelayListNote().flow().metadata.stateFlow
fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent
suspend fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
val event = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList
return event?.let { decryptionCache.relays(it) } ?: DefaultSearchRelayList
}
val flow =
getSearchRelayListFlow()
.map { normalizeSearchRelayListWithBackup(it.note) }
.onStart { emit(normalizeSearchRelayListWithBackup(getSearchRelayListNote())) }
.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Companion.Eagerly,
emptySet(),
)
suspend fun saveRelayList(searchRelays: List<NormalizedRelayUrl>): SearchRelayListEvent {
val relayListForSearch = getSearchRelayList()
return if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) {
SearchRelayListEvent.Companion.updateRelayList(
earlierVersion = relayListForSearch,
relays = searchRelays,
signer = signer,
)
} else {
SearchRelayListEvent.Companion.create(
relays = searchRelays,
signer = signer,
)
}
}
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,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.nip51Lists.trustedRelays
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
class TrustedRelayListDecryptionCache(
signer: NostrSigner,
) : GenericRelayListCache<TrustedRelayListEvent>(signer)

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